From 2ce5bac9e37c2c0052b547a4c13d00e3f432caa8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 14:22:24 +0900 Subject: [PATCH] =?UTF-8?q?chore(=EC=8B=B1=ED=81=AC):=20=EC=8B=9C=EB=86=80?= =?UTF-8?q?=EB=A1=9C=EC=A7=80=20=EC=82=AC=EB=B3=B8=20D=20E=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=C2=B7=20=EB=BF=8C=EB=A6=AC=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=3D=20=EB=9E=A9=ED=83=91=20main=20=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EB=82=B4=EC=9A=A9=20=E2=80=94=20=EC=A0=84=EC=97=AD?= =?UTF-8?q?=20=EA=B9=83=20=EC=8B=B1=ED=81=AC=20=EC=95=9E=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + D01_PlanRegister/D01_PlanRegister_Router.py | 99 +++ D01_PlanRegister/D01_PlanRegister_UI_Page.ts | 170 +++++ .../draft/D01_PlanRegister_Route.ts | 15 + D02_PlanBase/D02_PlanBase_Router.py | 206 ++++++ D02_PlanBase/D02_PlanBase_UI_Page.ts | 261 +++++++ D02_PlanBase/D02_PlanBase_UI_Style.css | 51 ++ D02_PlanBase/draft/D02_PlanBase_Route.ts | 16 + D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py | 400 ++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 127 ++++ D03_PlanAnalysis/D03_PlanAnalysis_Edit.py | 169 +++++ D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 415 +++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py | 221 ++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts | 148 ++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts | 123 ++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts | 186 +++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 413 +++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 693 +++++++++++++++++ .../D03_PlanAnalysis_UI_Style.css | 129 ++++ .../draft/D03_PlanAnalysis_Route.ts | 16 + .../logic/D03_PlanAnalysis_Edit.ts | 173 +++++ .../logic/D03_PlanAnalysis_Expect.ts | 124 ++++ .../logic/D03_PlanAnalysis_Plan.ts | 232 ++++++ D04_PlanConfirm/D04_PlanConfirm_Email.py | 50 ++ D04_PlanConfirm/D04_PlanConfirm_Export.py | 279 +++++++ D04_PlanConfirm/D04_PlanConfirm_Link.py | 346 +++++++++ D04_PlanConfirm/D04_PlanConfirm_Repository.py | 119 +++ D04_PlanConfirm/D04_PlanConfirm_Router.py | 322 ++++++++ D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts | 336 +++++++++ D04_PlanConfirm/D04_PlanConfirm_UI_Style.css | 62 ++ .../draft/D04_PlanConfirm_Route.ts | 16 + E01_StabRegister/E01_StabRegister_Router.py | 130 ++++ E01_StabRegister/E01_StabRegister_Source.py | 156 ++++ .../E01_StabRegister_UI_Missing.css | 21 + .../E01_StabRegister_UI_Missing.ts | 24 + E01_StabRegister/E01_StabRegister_UI_Page.ts | 98 +++ .../draft/E01_StabRegister_Route.ts | 15 + E02_StabGround/E02_StabGround_Model.py | 361 +++++++++ E02_StabGround/E02_StabGround_Router.py | 154 ++++ E02_StabGround/E02_StabGround_UI_Page.ts | 252 +++++++ E02_StabGround/E02_StabGround_UI_Style.css | 186 +++++ E02_StabGround/E02_StabGround_UI_Tables.ts | 484 ++++++++++++ E02_StabGround/E02_StabGround_UI_Water.ts | 182 +++++ E02_StabGround/draft/E02_StabGround_Route.ts | 16 + E02_StabGround/logic/E02_StabGround_Check.ts | 230 ++++++ .../E03_StabSection_Engine_Describe.py | 140 ++++ E03_StabSection/E03_StabSection_Engine_GLE.py | 220 ++++++ E03_StabSection/E03_StabSection_Engine_LEM.py | 695 ++++++++++++++++++ .../E03_StabSection_Engine_Model.py | 293 ++++++++ .../E03_StabSection_Engine_Prob.py | 441 +++++++++++ .../E03_StabSection_Engine_Search.py | 628 ++++++++++++++++ .../E03_StabSection_Engine_Station.py | 118 +++ .../E03_StabSection_Engine_Support.py | 152 ++++ .../E03_StabSection_Engine_Surface.py | 185 +++++ E03_StabSection/E03_StabSection_Router.py | 476 ++++++++++++ .../E03_StabSection_Router_Prob.py | 400 ++++++++++ .../E03_StabSection_Router_WhatIf.py | 433 +++++++++++ E03_StabSection/E03_StabSection_Types.py | 89 +++ E03_StabSection/E03_StabSection_UI_List.ts | 120 +++ E03_StabSection/E03_StabSection_UI_Page.ts | 539 ++++++++++++++ E03_StabSection/E03_StabSection_UI_Plan.ts | 89 +++ E03_StabSection/E03_StabSection_UI_Prob.css | 116 +++ E03_StabSection/E03_StabSection_UI_Prob.ts | 385 ++++++++++ .../E03_StabSection_UI_ProbChart.ts | 225 ++++++ .../E03_StabSection_UI_Reinforce.ts | 315 ++++++++ E03_StabSection/E03_StabSection_UI_Result.ts | 299 ++++++++ E03_StabSection/E03_StabSection_UI_Section.ts | 424 +++++++++++ E03_StabSection/E03_StabSection_UI_Style.css | 690 +++++++++++++++++ .../E03_StabSection_UI_Support.css | 62 ++ E03_StabSection/E03_StabSection_UI_Support.ts | 300 ++++++++ E03_StabSection/E03_StabSection_UI_View3D.ts | 201 +++++ E03_StabSection/E03_StabSection_UI_Walls.css | 23 + E03_StabSection/E03_StabSection_View.py | 302 ++++++++ E03_StabSection/E03_StabSection_WallCheck.py | 220 ++++++ E03_StabSection/E03_StabSection_Walls.py | 114 +++ E03_StabSection/E03_StabSection_Walls_Node.ts | 102 +++ E03_StabSection/E03_StabSection_WhatIf.py | 199 +++++ E03_StabSection/E03_StabSection_Worker.py | 84 +++ .../draft/E03_StabSection_Route.ts | 16 + .../logic/E03_StabSection_Logic.ts | 414 +++++++++++ E03_StabSection/logic/E03_StabSection_Prob.ts | 266 +++++++ .../logic/E03_StabSection_Support.ts | 221 ++++++ E04_Stab3D/E04_Stab3D_Engine_Column.py | 414 +++++++++++ E04_Stab3D/E04_Stab3D_Engine_Grid.py | 310 ++++++++ E04_Stab3D/E04_Stab3D_Engine_Search.py | 679 +++++++++++++++++ E04_Stab3D/E04_Stab3D_Router.py | 238 ++++++ E04_Stab3D/E04_Stab3D_Run.py | 389 ++++++++++ E04_Stab3D/E04_Stab3D_UI_Map.ts | 381 ++++++++++ E04_Stab3D/E04_Stab3D_UI_Page.ts | 447 +++++++++++ E04_Stab3D/E04_Stab3D_UI_Settings.ts | 141 ++++ E04_Stab3D/E04_Stab3D_UI_Style.css | 286 +++++++ E04_Stab3D/E04_Stab3D_UI_View3D.ts | 242 ++++++ E04_Stab3D/draft/E04_Stab3D_Route.ts | 16 + E04_Stab3D/logic/E04_Stab3D_Mesh.ts | 171 +++++ E04_Stab3D/logic/E04_Stab3D_Raster.ts | 215 ++++++ E05_StabReport/E05_StabReport_Area.py | 130 ++++ E05_StabReport/E05_StabReport_Build.py | 494 +++++++++++++ E05_StabReport/E05_StabReport_Cells.py | 98 +++ E05_StabReport/E05_StabReport_Figure.py | 386 ++++++++++ E05_StabReport/E05_StabReport_Print.py | 231 ++++++ E05_StabReport/E05_StabReport_Prob.py | 145 ++++ E05_StabReport/E05_StabReport_Reinforce.py | 210 ++++++ E05_StabReport/E05_StabReport_Rock.py | 228 ++++++ E05_StabReport/E05_StabReport_Router.py | 240 ++++++ E05_StabReport/E05_StabReport_Section.py | 318 ++++++++ E05_StabReport/E05_StabReport_Store.py | 197 +++++ E05_StabReport/E05_StabReport_UI_Page.ts | 300 ++++++++ E05_StabReport/E05_StabReport_UI_Style.css | 81 ++ E05_StabReport/draft/E05_StabReport_Route.ts | 16 + E06_StabRock/E06_StabRock_Engine.py | 444 +++++++++++ E06_StabRock/E06_StabRock_Model.py | 464 ++++++++++++ E06_StabRock/E06_StabRock_Router.py | 206 ++++++ E06_StabRock/E06_StabRock_Stereonet.py | 137 ++++ E06_StabRock/E06_StabRock_UI_Joints.ts | 331 +++++++++ E06_StabRock/E06_StabRock_UI_Page.ts | 397 ++++++++++ E06_StabRock/E06_StabRock_UI_Result.ts | 235 ++++++ E06_StabRock/E06_StabRock_UI_Stereonet.ts | 193 +++++ E06_StabRock/E06_StabRock_UI_Style.css | 491 +++++++++++++ E06_StabRock/draft/E06_StabRock_Route.ts | 16 + E07_StabArea/E07_StabArea_Engine.py | 328 +++++++++ E07_StabArea/E07_StabArea_Router.py | 350 +++++++++ E07_StabArea/E07_StabArea_UI_Map.ts | 296 ++++++++ E07_StabArea/E07_StabArea_UI_Page.ts | 497 +++++++++++++ E07_StabArea/E07_StabArea_UI_Style.css | 236 ++++++ E07_StabArea/draft/E07_StabArea_Route.ts | 16 + E07_StabArea/logic/E07_StabArea_Raster.ts | 164 +++++ main.py | 35 + package.json | 1 + requirements-dev.txt | 4 + requirements.txt | 1 - tsconfig.json | 2 +- 131 files changed, 30069 insertions(+), 2 deletions(-) create mode 100644 D01_PlanRegister/D01_PlanRegister_Router.py create mode 100644 D01_PlanRegister/D01_PlanRegister_UI_Page.ts create mode 100644 D01_PlanRegister/draft/D01_PlanRegister_Route.ts create mode 100644 D02_PlanBase/D02_PlanBase_Router.py create mode 100644 D02_PlanBase/D02_PlanBase_UI_Page.ts create mode 100644 D02_PlanBase/D02_PlanBase_UI_Style.css create mode 100644 D02_PlanBase/draft/D02_PlanBase_Route.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Api.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Edit.py create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Router.py create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css create mode 100644 D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts create mode 100644 D04_PlanConfirm/D04_PlanConfirm_Email.py create mode 100644 D04_PlanConfirm/D04_PlanConfirm_Export.py create mode 100644 D04_PlanConfirm/D04_PlanConfirm_Link.py create mode 100644 D04_PlanConfirm/D04_PlanConfirm_Repository.py create mode 100644 D04_PlanConfirm/D04_PlanConfirm_Router.py create mode 100644 D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts create mode 100644 D04_PlanConfirm/D04_PlanConfirm_UI_Style.css create mode 100644 D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts create mode 100644 E01_StabRegister/E01_StabRegister_Router.py create mode 100644 E01_StabRegister/E01_StabRegister_Source.py create mode 100644 E01_StabRegister/E01_StabRegister_UI_Missing.css create mode 100644 E01_StabRegister/E01_StabRegister_UI_Missing.ts create mode 100644 E01_StabRegister/E01_StabRegister_UI_Page.ts create mode 100644 E01_StabRegister/draft/E01_StabRegister_Route.ts 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/E02_StabGround_UI_Water.ts create mode 100644 E02_StabGround/draft/E02_StabGround_Route.ts create mode 100644 E02_StabGround/logic/E02_StabGround_Check.ts create mode 100644 E03_StabSection/E03_StabSection_Engine_Describe.py create mode 100644 E03_StabSection/E03_StabSection_Engine_GLE.py 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_Prob.py create mode 100644 E03_StabSection/E03_StabSection_Engine_Search.py create mode 100644 E03_StabSection/E03_StabSection_Engine_Station.py create mode 100644 E03_StabSection/E03_StabSection_Engine_Support.py create mode 100644 E03_StabSection/E03_StabSection_Engine_Surface.py create mode 100644 E03_StabSection/E03_StabSection_Router.py create mode 100644 E03_StabSection/E03_StabSection_Router_Prob.py create mode 100644 E03_StabSection/E03_StabSection_Router_WhatIf.py create mode 100644 E03_StabSection/E03_StabSection_Types.py create mode 100644 E03_StabSection/E03_StabSection_UI_List.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Page.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Plan.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Prob.css create mode 100644 E03_StabSection/E03_StabSection_UI_Prob.ts create mode 100644 E03_StabSection/E03_StabSection_UI_ProbChart.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Reinforce.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Result.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Section.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Style.css create mode 100644 E03_StabSection/E03_StabSection_UI_Support.css create mode 100644 E03_StabSection/E03_StabSection_UI_Support.ts create mode 100644 E03_StabSection/E03_StabSection_UI_View3D.ts create mode 100644 E03_StabSection/E03_StabSection_UI_Walls.css create mode 100644 E03_StabSection/E03_StabSection_View.py create mode 100644 E03_StabSection/E03_StabSection_WallCheck.py create mode 100644 E03_StabSection/E03_StabSection_Walls.py create mode 100644 E03_StabSection/E03_StabSection_Walls_Node.ts create mode 100644 E03_StabSection/E03_StabSection_WhatIf.py create mode 100644 E03_StabSection/E03_StabSection_Worker.py create mode 100644 E03_StabSection/draft/E03_StabSection_Route.ts create mode 100644 E03_StabSection/logic/E03_StabSection_Logic.ts create mode 100644 E03_StabSection/logic/E03_StabSection_Prob.ts create mode 100644 E03_StabSection/logic/E03_StabSection_Support.ts create mode 100644 E04_Stab3D/E04_Stab3D_Engine_Column.py create mode 100644 E04_Stab3D/E04_Stab3D_Engine_Grid.py create mode 100644 E04_Stab3D/E04_Stab3D_Engine_Search.py create mode 100644 E04_Stab3D/E04_Stab3D_Router.py create mode 100644 E04_Stab3D/E04_Stab3D_Run.py create mode 100644 E04_Stab3D/E04_Stab3D_UI_Map.ts create mode 100644 E04_Stab3D/E04_Stab3D_UI_Page.ts create mode 100644 E04_Stab3D/E04_Stab3D_UI_Settings.ts create mode 100644 E04_Stab3D/E04_Stab3D_UI_Style.css create mode 100644 E04_Stab3D/E04_Stab3D_UI_View3D.ts create mode 100644 E04_Stab3D/draft/E04_Stab3D_Route.ts create mode 100644 E04_Stab3D/logic/E04_Stab3D_Mesh.ts create mode 100644 E04_Stab3D/logic/E04_Stab3D_Raster.ts create mode 100644 E05_StabReport/E05_StabReport_Area.py create mode 100644 E05_StabReport/E05_StabReport_Build.py create mode 100644 E05_StabReport/E05_StabReport_Cells.py create mode 100644 E05_StabReport/E05_StabReport_Figure.py create mode 100644 E05_StabReport/E05_StabReport_Print.py create mode 100644 E05_StabReport/E05_StabReport_Prob.py create mode 100644 E05_StabReport/E05_StabReport_Reinforce.py create mode 100644 E05_StabReport/E05_StabReport_Rock.py create mode 100644 E05_StabReport/E05_StabReport_Router.py create mode 100644 E05_StabReport/E05_StabReport_Section.py create mode 100644 E05_StabReport/E05_StabReport_Store.py create mode 100644 E05_StabReport/E05_StabReport_UI_Page.ts create mode 100644 E05_StabReport/E05_StabReport_UI_Style.css create mode 100644 E05_StabReport/draft/E05_StabReport_Route.ts create mode 100644 E06_StabRock/E06_StabRock_Engine.py create mode 100644 E06_StabRock/E06_StabRock_Model.py create mode 100644 E06_StabRock/E06_StabRock_Router.py create mode 100644 E06_StabRock/E06_StabRock_Stereonet.py create mode 100644 E06_StabRock/E06_StabRock_UI_Joints.ts create mode 100644 E06_StabRock/E06_StabRock_UI_Page.ts create mode 100644 E06_StabRock/E06_StabRock_UI_Result.ts create mode 100644 E06_StabRock/E06_StabRock_UI_Stereonet.ts create mode 100644 E06_StabRock/E06_StabRock_UI_Style.css create mode 100644 E06_StabRock/draft/E06_StabRock_Route.ts create mode 100644 E07_StabArea/E07_StabArea_Engine.py create mode 100644 E07_StabArea/E07_StabArea_Router.py create mode 100644 E07_StabArea/E07_StabArea_UI_Map.ts create mode 100644 E07_StabArea/E07_StabArea_UI_Page.ts create mode 100644 E07_StabArea/E07_StabArea_UI_Style.css create mode 100644 E07_StabArea/draft/E07_StabArea_Route.ts create mode 100644 E07_StabArea/logic/E07_StabArea_Raster.ts create mode 100644 requirements-dev.txt diff --git a/.gitignore b/.gitignore index 122ae73b4..0b33c3120 100644 --- a/.gitignore +++ b/.gitignore @@ -72,5 +72,7 @@ config/b07_extent_node/ config/b07_note_node/ # 계산 등록부 서버 Node 입구 번들 — `npm run build:design-calc` 산출물(472-3). config/design_calc_node/ +# E03 안정성 모형 벽 도형 번들 — `npm run build:e03-walls` 산출물(532-4). +config/e03_walls_node/ ~$* diff --git a/D01_PlanRegister/D01_PlanRegister_Router.py b/D01_PlanRegister/D01_PlanRegister_Router.py new file mode 100644 index 000000000..c83868057 --- /dev/null +++ b/D01_PlanRegister/D01_PlanRegister_Router.py @@ -0,0 +1,99 @@ +"""D01 예정노선 등록 API · 대시보드 「받은 예정노선」 (요구 345 · 요구서 3-1 · 2-2).""" + +from typing import Any + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from B05_Profile.B05_Profile_Engine_Grade import resolve_design_speed +from B05_Profile.B05_Profile_PageSettings import grade_class_of +from common_util.common_util_auth import is_forest_service, require_company +from common_util.common_util_project_kind import create_kind_project +from config.config_db import get_db_pool + +router = APIRouter(prefix="/api/d01", tags=["D01_PlanRegister"]) +dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D01_PlanRegister"]) + + +class CreatePlanRequest(BaseModel): + project_name: str = Field(..., min_length=1, max_length=255) + project_year: int = Field(..., ge=2000, le=2100) + project_year_end: int | None = Field(default=None, ge=2000, le=2100) + region: str = Field(..., min_length=1, max_length=100) + road_type: str = Field(..., pattern="^(main|fire|work)$") + design_speed_kph: int | None = None + start_chainage_m: float | None = Field(default=None, ge=0) + end_chainage_m: float | None = Field(default=None, ge=0) + + +@router.post("/project") +async def post_plan_project( + request: Request, + payload: CreatePlanRequest, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, str]: + """예정노선 프로젝트 생성 — 산림청(시스템관리자 · *.go.kr)만.""" + if not is_forest_service(session): + raise HTTPException(status_code=403, detail="예정노선은 산림청만 만들 수 있습니다.") + if session.get("company_id") is None: + raise HTTPException(status_code=403, detail="회사 연결이 필요합니다.") + year_end = payload.project_year_end or payload.project_year + if year_end < payload.project_year: + raise HTTPException(status_code=400, detail="사업연도 끝이 시작보다 앞입니다.") + start_m, end_m = payload.start_chainage_m, payload.end_chainage_m + if start_m is not None and end_m is not None and start_m >= end_m: + raise HTTPException( + status_code=400, detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다." + ) + speed = resolve_design_speed(grade_class_of(payload.road_type, {}), payload.design_speed_kph) + project_id = await create_kind_project( + request=request, + user_id=int(session["user_id"]), + company_id=int(session["company_id"]), + kind="PLAN", + name=payload.project_name.strip(), + region=payload.region.strip(), + road_type=payload.road_type, + project_year=payload.project_year, + project_year_end=year_end, + design_speed_kph=speed, + route_start_m=start_m, + route_end_m=end_m, + ) + return {"project_id": project_id} + + +@dashboard_router.get("/plan-routes/assigned") +async def assigned_plan_routes( + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """우리 회사에 지정된 예정노선 연도 — 확정된 것(또는 이미 연결한 것) · 연결 상태 · 업데이트.""" + from D04_PlanConfirm.D04_PlanConfirm_Link import update_available + + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + """SELECT a.id AS assignee_id, a.project_id, p.name AS project_name, a.year, + l.id AS linked_project_id, l.name AS linked_project_name, + l.source_fingerprint, y.route_json, y.confirmed_at + FROM plan_route_assignees a + JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL + LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year + LEFT JOIN projects l ON l.id = a.linked_project_id AND l.deleted_at IS NULL + WHERE a.company_id = %s + AND (y.confirmed_at IS NOT NULL OR l.id IS NOT NULL) + ORDER BY p.name, a.year""", + (session.get("company_id"),), + ) + rows = await cursor.fetchall() + routes = [] + for row in rows: + route_json, fingerprint = row.pop("route_json"), row.pop("source_fingerprint") + confirmed = row["confirmed_at"] is not None + routes.append( + {**row, "update_available": update_available(route_json, fingerprint, confirmed)} + ) + return {"status": "success", "routes": routes} diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts new file mode 100644 index 000000000..49c819408 --- /dev/null +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -0,0 +1,170 @@ +/* ============================================================================= + * D01_PlanRegister_UI_Page.ts + * D01 예정노선 등록(요구서 345 3-1 · PLAN 529-1) — B02 등록 화면 꼴 · 도면 칸 없음. + * 칸 = 사업연도 시작 · 끝 · 사업지역 · 종류 「노선 계획」(고정) · 임도 종류(B02 값) · 설계속도 + * (종류별 고를 수 있는 값 · 기본 = 가장 낮은 값 = B05 resolve_design_speed) · 프로젝트명(B02 이름 + * 조합기 · 연도 자리 = 「시작~끝」) · 노선 시작 · 종료 누가거리. + * [등록] = POST /api/d01/project → 현재 프로젝트 · D02 로. + * ========================================================================== */ + +import "../B02_ProjRegister/B02_ProjRegister_UI_Style.css"; +import { ROUTES } from "@config/config_frontend"; +import { + createButton, + createCard, + createInputField, + createSelectField, +} from "@ui/ui_template_elements"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { createHelpButton } from "@ui/ui_template_help"; +import { t } from "@ui/ui_template_locale"; +import { stationLabel } from "../common_util/common_util_station"; +import { createGroupProject } from "../A00_Common/group_workflow"; +import { navigateTo } from "../A00_Common/router"; +import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name"; +import { SPEED_CHOICES } from "../B05_Profile/logic/B05_Profile_Grade_Criteria"; + +/** B02 임도 종류 → 설계속도 기준 등급(B05 grade_class_of 와 같음) */ +const GRADE_CLASS: Record = { main: "trunk", fire: "fire", work: "work" }; + +/** 측점 간격 — D03 `PLAN_STATION_INTERVAL_M` 와 같음 */ +const STATION_INTERVAL_M = 20; + +const numberOrNull = (text: string): number | null => + text.trim() === "" ? null : Number.parseFloat(text); + +export function renderD01PlanRegister(root: HTMLElement): void { + const year = new Date().getFullYear(); + const yearStart = createInputField({ + label: t("DE_Reg_YearStart"), + type: "number", + value: String(year), + min: 2000, + max: 2100, + }); + const yearEnd = createInputField({ + label: t("DE_Reg_YearEnd"), + type: "number", + value: String(year), + min: 2000, + max: 2100, + }); + const region = createInputField({ + label: t("B02_Proj_Field_Region"), + placeholder: t("B02_Proj_Field_Region_Placeholder"), + required: true, + }); + const kind = createInputField({ label: t("DE_Reg_Kind"), value: t("DE_Reg_KindPlan") }); + kind.input.readOnly = true; + const roadType = createSelectField({ + label: t("B02_Proj_Field_RoadType"), + options: [ + { value: "main", text: t("B02_Proj_RoadType_Main") }, + { value: "fire", text: t("B02_Proj_RoadType_Fire") }, + { value: "work", text: t("B02_Proj_RoadType_Work") }, + ], + }); + const speed = createSelectField({ label: t("DE_Reg_DesignSpeed"), options: [] }); + const name = createInputField({ label: t("DE_Reg_Name"), required: true }); + const startM = createInputField({ label: t("DE_Reg_StartM"), type: "number", min: 0 }); + const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 }); + // 누가거리 칸 옆 즉시 측점 표기(저장값 · 서버 검사는 m 그대로) + for (const field of [startM, endM]) { + const shown = document.createElement("span"); + shown.style.cssText = "font-size:12px;color:var(--color-text-muted)"; + shown.dataset.station = ""; + field.root.insertBefore(shown, field.root.lastElementChild); + field.input.addEventListener("input", () => { + const value = numberOrNull(field.input.value); + shown.textContent = + value !== null && Number.isFinite(value) && value >= 0 + ? `= ${stationLabel(value, STATION_INTERVAL_M, 1)}` + : ""; + }); + } + + // 설계속도 = 종류별 고를 수 있는 값 · 기본 = 가장 낮은 값(20) + const syncSpeeds = (): void => { + const choices = SPEED_CHOICES[GRADE_CLASS[roadType.select.value]] ?? [20]; + speed.setOptions( + choices.map((kph) => ({ value: String(kph), text: `${kph} km/h` })), + String(Math.min(...choices)), + ); + }; + + // 이름 = B02 조합기 그대로(손대기 전 = 자동 · 고친 뒤 = 조각만 갈아 끼움) + const composer = createNameComposer(); + const years = (): string => { + const [a, b] = [yearStart.input.value.trim(), yearEnd.input.value.trim()]; + return b && b !== a ? `${a}~${b}` : a; + }; + const syncName = (): void => { + name.input.value = composer.update({ + year: years(), + region: region.input.value, + type: roadType.select.selectedOptions[0]?.textContent ?? "", + }); + }; + name.input.addEventListener("input", () => composer.edit(name.input.value)); + for (const field of [yearStart, yearEnd, region]) field.input.addEventListener("input", syncName); + roadType.select.addEventListener("change", () => (syncSpeeds(), syncName())); + syncSpeeds(); + syncName(); + + const submit = (): void => { + const fields = [yearStart, yearEnd, region, name, startM, endM]; + fields.forEach((field) => field.setError()); + const y0 = Number.parseInt(yearStart.input.value, 10); + const y1 = Number.parseInt(yearEnd.input.value, 10); + const [s, e] = [numberOrNull(startM.input.value), numberOrNull(endM.input.value)]; + const errors: Array<[(typeof fields)[number], string | false]> = [ + [yearStart, !(y0 >= 2000 && y0 <= 2100) && t("Common_Validation_NumberRange")], + [yearEnd, !(y1 >= y0 && y1 <= 2100) && t("DE_Reg_YearOrder")], + [region, !region.input.value.trim() && t("B02_Proj_Error_Required")], + [name, !name.input.value.trim() && t("B02_Proj_Error_Required")], + [startM, s !== null && !(s >= 0) && t("Common_Validation_NumberRange")], + [endM, e !== null && s !== null && !(e > s) && t("DE_Reg_ChainOrder")], + ]; + const bad = errors.filter(([, message]) => message); + bad.forEach(([field, message]) => field.setError(message as string)); + if (bad.length) return; + void createGroupProject( + "/d01/project", + { + project_name: name.input.value.trim(), + project_year: y0, + project_year_end: y1, + region: region.input.value.trim(), + road_type: roadType.select.value, + design_speed_kph: Number(speed.select.value), + start_chainage_m: s, + end_chainage_m: e, + }, + ROUTES.D02_PLAN_BASE, + ); + }; + + const grid = document.createElement("div"); + grid.className = "b02-proj__grid"; + grid.append( + ...[yearStart, yearEnd, region, kind, roadType, speed, name, startM, endM].map((f) => f.root), + ); + const actions = document.createElement("div"); + actions.className = "ui-general-block__actions b02-proj__actions"; + actions.append( + createHelpButton(), + createButton({ + label: t("Common_Btn_Cancel"), + variant: "ghost", + onClick: () => navigateTo(ROUTES.B01_ACCOUNT), + }), + createButton({ label: t("DE_Reg_Submit"), variant: "filled", onClick: submit }), + ); + const layout = createGeneralLayout({ + pageClass: "b02-proj", + title: t("DE_Reg_PlanTitle"), + subtitle: t("DE_Reg_PlanSubtitle"), + content: createCard({ body: [grid, actions], raised: true }), + }); + root.append(layout.root); +} diff --git a/D01_PlanRegister/draft/D01_PlanRegister_Route.ts b/D01_PlanRegister/draft/D01_PlanRegister_Route.ts new file mode 100644 index 000000000..47ed265cc --- /dev/null +++ b/D01_PlanRegister/draft/D01_PlanRegister_Route.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * D01_PlanRegister_Route.ts + * 등록 화면 길 선언(PLAN 529-1 · 528-2) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 필요(B02 와 같음) · 꼬리말 없음 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D01_PLAN_REGISTER, + workflow: true, + footerless: true, + load: async () => (await import("../D01_PlanRegister_UI_Page")).renderD01PlanRegister, +}; diff --git a/D02_PlanBase/D02_PlanBase_Router.py b/D02_PlanBase/D02_PlanBase_Router.py new file mode 100644 index 000000000..b61e39661 --- /dev/null +++ b/D02_PlanBase/D02_PlanBase_Router.py @@ -0,0 +1,206 @@ +"""D 예정노선 1단계 기준 지정 — 연도별 시점 · 종점 정본 API (요구 345 · 요구서 3-2 · PLAN 529-3). + +- `GET /api/projects/{id}/plan/points` — 사업연도 연도마다 시점 · 종점(경위도 + TM) · 빈 = null +- `PUT …/plan/points` — 경위도를 받아 서버가 TM 을 셈 → `plan_route_years` 행 + +규칙: 직전 연도 종점 = 다음 연도 시점(서버가 맞춤 · 보낸 시점은 첫 연도만 씀) · +범위 밖 · 빈 연도 거절 · 좌표계 = 모든 지점 평균 경도의 TM 띠 한 벌 · +지점이 바뀐 연도는 분석 노선(route_json · fingerprint · confirmed_at) 비움 · +고침 = 산림청만(지정 회사 GET 은 공용 판정 `require_project_access` 몫). +""" + +from __future__ import annotations + +from typing import Any +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from common_util.common_util_auth import is_forest_service, require_company +from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon, tm_to_lonlat +from config.config_db import get_db_pool + +router = APIRouter(prefix="/api/projects", tags=["D02 Plan Base"]) + +SAME_M = 0.001 # 1 mm 안이면 같은 지점 — 되저장으로 분석 노선을 지우지 않음 + + +class LonLat(BaseModel): + lon: float = Field(..., ge=120, le=135) + lat: float = Field(..., ge=30, le=45) + + +class YearPoints(BaseModel): + year: int + start: LonLat | None = None + end: LonLat | None = None + + +class PointsRequest(BaseModel): + years: list[YearPoints] + + +def chain_years(year_start: int, year_end: int, items: list[YearPoints]) -> list[dict[str, Any]]: + """연도 범위를 잇기 — [{year, start, end}] 경위도 · 시점 = 직전 종점(첫 연도만 보낸 값).""" + by_year: dict[int, YearPoints] = {} + for item in items: + if not year_start <= item.year <= year_end: + raise HTTPException( + status_code=400, + detail=f"{item.year}년은 사업연도({year_start}~{year_end}) 밖입니다.", + ) + if item.year in by_year: + raise HTTPException(status_code=400, detail=f"{item.year}년이 두 번 왔습니다.") + by_year[item.year] = item + rows: list[dict[str, Any]] = [] + previous: LonLat | None = None + for year in range(year_start, year_end + 1): + item = by_year.get(year) + start = previous or (item.start if item else None) + end = item.end if item else None + if start is None or end is None: + side = "시점" if start is None else "종점" + raise HTTPException(status_code=400, detail=f"{year}년 {side}이 비었습니다.") + rows.append({"year": year, "start": start, "end": end}) + previous = end + return rows + + +def with_tm(rows: list[dict[str, Any]]) -> tuple[int, list[dict[str, Any]]]: + """(epsg, 행마다 start_x · start_y · end_x · end_y 더함).""" + lons = [p.lon for row in rows for p in (row["start"], row["end"])] + epsg = tm_epsg_for_lon(sum(lons) / len(lons)) + out = [] + for row in rows: + sx, sy = lonlat_to_tm(row["start"].lon, row["start"].lat, epsg) + ex, ey = lonlat_to_tm(row["end"].lon, row["end"].lat, epsg) + out.append({"year": row["year"], "start_x": sx, "start_y": sy, "end_x": ex, "end_y": ey}) + return epsg, out + + +def changed(new: dict[str, Any], old: dict[str, Any] | None, epsg: int) -> bool: + if not old or old.get("epsg") is None or int(old["epsg"]) != epsg: + return True + keys = ("start_x", "start_y", "end_x", "end_y") + return any(old[k] is None or abs(float(old[k]) - new[k]) > SAME_M for k in keys) + + +async def _load(project_id: UUID) -> tuple[dict[str, Any], dict[int, dict[str, Any]]]: + """(예정노선 프로젝트 칸, 연도 → 지금 행).""" + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT project_kind, project_year, project_year_end FROM projects" + " WHERE id = %s AND deleted_at IS NULL", + (str(project_id),), + ) + project = await cursor.fetchone() + await cursor.execute( + "SELECT year, start_x, start_y, end_x, end_y, epsg FROM plan_route_years" + " WHERE project_id = %s", + (str(project_id),), + ) + rows = await cursor.fetchall() + if not project: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if project["project_kind"] != "PLAN" or project["project_year"] is None: + raise HTTPException(status_code=400, detail="예정노선 프로젝트가 아닙니다.") + return project, {int(row["year"]): row for row in rows} + + +async def _store( + project_id: UUID, epsg: int, writes: list[dict[str, Any]], drops: list[int] +) -> None: + """바뀐 연도만 넣고(분석 노선 비움) · 범위 밖 연도 행은 지움 — 한 묶음.""" + async with get_db_pool().acquire() as connection: + await connection.begin() + try: + async with connection.cursor() as cursor: + for row in writes: + await cursor.execute( + "INSERT INTO plan_route_years" + " (project_id, year, start_x, start_y, end_x, end_y, epsg)" + " VALUES (%s, %s, %s, %s, %s, %s, %s)" + " ON DUPLICATE KEY UPDATE start_x = VALUES(start_x)," + " start_y = VALUES(start_y), end_x = VALUES(end_x), end_y = VALUES(end_y)," + " epsg = VALUES(epsg), route_json = NULL, fingerprint = NULL," + " confirmed_at = NULL", + ( + str(project_id), + row["year"], + row["start_x"], + row["start_y"], + row["end_x"], + row["end_y"], + epsg, + ), + ) + for year in drops: + await cursor.execute( + "DELETE FROM plan_route_years WHERE project_id = %s AND year = %s", + (str(project_id), year), + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + +def _point(x: Any, y: Any, epsg: Any) -> dict[str, float] | None: + if x is None or y is None or epsg is None: + return None + lon, lat = tm_to_lonlat(float(x), float(y), int(epsg)) + return {"lon": lon, "lat": lat, "x": float(x), "y": float(y)} + + +def _view(project: dict[str, Any], rows: dict[int, dict[str, Any]]) -> dict[str, Any]: + start = int(project["project_year"]) + end = int(project["project_year_end"] or start) + years = [] + for year in range(start, end + 1): + row = rows.get(year) or {} + years.append( + { + "year": year, + "start": _point(row.get("start_x"), row.get("start_y"), row.get("epsg")), + "end": _point(row.get("end_x"), row.get("end_y"), row.get("epsg")), + } + ) + epsgs = {int(r["epsg"]) for r in rows.values() if r.get("epsg") is not None} + return { + "status": "success", + "year_start": start, + "year_end": end, + "epsg": epsgs.pop() if len(epsgs) == 1 else None, + "years": years, + } + + +@router.get("/{project_id}/plan/points", response_model=None) +async def get_plan_points(project_id: UUID) -> dict[str, Any]: + project, rows = await _load(project_id) + return _view(project, rows) + + +@router.put("/{project_id}/plan/points", response_model=None) +async def put_plan_points( + project_id: UUID, + payload: PointsRequest, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + if not is_forest_service(session): + raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.") + project, old = await _load(project_id) + start = int(project["project_year"]) + end = int(project["project_year_end"] or start) + epsg, rows = with_tm(chain_years(start, end, payload.years)) + writes = [row for row in rows if changed(row, old.get(row["year"]), epsg)] + drops = [year for year in old if not start <= year <= end] + if writes or drops: + await _store(project_id, epsg, writes, drops) + _, now = await _load(project_id) + return {**_view(project, now), "changed_years": [row["year"] for row in writes]} diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts new file mode 100644 index 000000000..779805682 --- /dev/null +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -0,0 +1,261 @@ +/* ============================================================================= + * D02_PlanBase_UI_Page.ts + * D02 1단계 기준 지정(요구서 345 3-2 · PLAN 529-3) — 메인 = 카카오맵 · 좌 = 연도 지점 표. + * 지점 = 경계점 목록 P0..Pn(연도 i = P(i) → P(i+1)) · 직전 종점 = 다음 시점이 모양 그대로. + * 조작(끌기 · 지도 누름 · 경위도 입력) = sessionStorage 캐시 · [저장] · [>] = PUT 정본(서버가 TM 셈) + * · [>] = 저장 성공 뒤 D03 · 자동저장 없음(CLAUDE.md 5장). + * 산림청이 아니면(지정 회사 = 보기만) 끌기 · 누름 · 입력 · [저장] 없음 · [>] = 그냥 D03. + * ========================================================================== */ + +import "./D02_PlanBase_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { + createButton, + el, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { createPanelCard } from "@ui/ui_template_collapsible"; +import { editMap, type MapPin } from "@ui/ui_template_kakao_map"; +import { t } from "@ui/ui_template_locale"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { navigateTo } from "../A00_Common/router"; + +interface LonLat { + lon: number; + lat: number; +} +interface ServerPoint extends LonLat { + x: number; + y: number; +} +interface PointsView { + year_start: number; + year_end: number; + epsg: number | null; + years: Array<{ year: number; start: ServerPoint | null; end: ServerPoint | null }>; +} + +const YEAR_COLORS = ["#E60046", "#1F6FEB", "#1A9E55", "#D97706", "#8B5CF6", "#0891B2"]; +const KOREA_CENTER: [number, number] = [127.8, 36.3]; +const DIGITS = 7; // 경위도 소수 7 자리 ≈ 1 cm + +const cacheKey = (projectId: string): string => `d02_points_${projectId}`; + +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 data = (await response.json().catch(() => ({}))) as { detail?: unknown }; + if (!response.ok) + throw new Error(typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`); + return data as T; +} + +/** 서버 연도 행 → 경계점 목록(연도 수 + 1) */ +function boundaryPoints(view: PointsView): Array { + const pick = (p: ServerPoint | null): LonLat | null => (p ? { lon: p.lon, lat: p.lat } : null); + return [pick(view.years[0]?.start ?? null), ...view.years.map((y) => pick(y.end))]; +} + +export async function renderD02PlanBase(root: HTMLElement): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const mapHost = el("div", { className: "d02-map" }); + const table = el("div", { className: "d02-points" }); + const info = el("p", { className: "d02-info" }); + const card = createPanelCard(t("D02_Points_Title"), false); + const pointsHint = el("p", { className: "d02-info", text: t("D02_Points_Hint") }); + card.append(pointsHint, table, info); + const save = createButton({ + label: t("D02_Save"), + variant: "filled", + onClick: () => void store(), + }); + const actions = el("div", { className: "ui-sidebar-actions", children: [save] }); + const left = el("div", { className: "ui-sidebar-panel d02-panel", children: [card, actions] }); + mountGroupStep(root, ROUTES.D02_PLAN_BASE, { mainContent: mapHost, leftPanel: left }); + + let view: PointsView | null = null; + let points: Array = []; + let active: number | null = null; // 지도 누름이 놓을 지점 + let editable = false; // 산림청만 고침 + + const years = (): number[] => (view ? view.years.map((y) => y.year) : []); + const pointName = (i: number): string => { + const ys = years(); + if (i === 0) return `${ys[0]} ${t("D02_Start")}`; + if (i === ys.length) return `${ys[i - 1]} ${t("D02_End")}`; + return `${ys[i - 1]} ${t("D02_End")} · ${ys[i]} ${t("D02_Start")}`; + }; + const firstEmpty = (): number | null => { + const i = points.findIndex((p) => !p); + return i < 0 ? null : i; + }; + const remember = (): void => + sessionStorage.setItem(cacheKey(projectId), JSON.stringify({ points })); + + const map = editMap({ + hint: t("D02_Map_Hint"), + center: () => KOREA_CENTER, + onMove: (key, lon, lat) => place(Number(key), { lon, lat }), + onClick: (lon, lat) => { + if (!editable) return; + if (active === null) return showToast(t("D02_Pick_Point"), "info"); + place(active, { lon, lat }); + }, + }); + mapHost.append(map.root); + + function place(index: number, at: LonLat): void { + points[index] = { lon: +at.lon.toFixed(DIGITS), lat: +at.lat.toFixed(DIGITS) }; + if (index === active) active = firstEmpty(); // 놓은 지점 = 다음 빈 지점으로 + remember(); + paint(String(index)); + } + + /** focus = 고친 지점(보기 밖이면 그리로) · 빈 값 = 보기 그대로 */ + function drawMap(focus = ""): void { + const pins: MapPin[] = points.flatMap((p, i) => + p ? [{ key: String(i), name: pointName(i), lon: p.lon, lat: p.lat, movable: editable }] : [], + ); + const lines: Array> = []; + const colors: string[] = []; + years().forEach((_, i) => { + const a = points[i]; + const b = points[i + 1]; + if (!a || !b) return; + lines.push([ + [a.lat, a.lon], + [b.lat, b.lon], + ]); + colors.push(YEAR_COLORS[i % YEAR_COLORS.length]); + }); + map.set(pins, lines, colors, focus); + } + + function drawTable(): void { + const rows = points.map((p, i) => { + const lon = el("input", { className: "d02-points__input" }) as HTMLInputElement; + const lat = el("input", { className: "d02-points__input" }) as HTMLInputElement; + lon.type = lat.type = "number"; + lon.step = lat.step = "any"; + lon.placeholder = t("D02_Lon"); + lat.placeholder = t("D02_Lat"); + lon.value = p ? String(p.lon) : ""; + lat.value = p ? String(p.lat) : ""; + lon.disabled = lat.disabled = !editable; + lon.dataset.point = lat.dataset.point = String(i); + lon.dataset.axis = "lon"; + lat.dataset.axis = "lat"; + const typed = (): void => { + const at = { lon: Number.parseFloat(lon.value), lat: Number.parseFloat(lat.value) }; + const ok = at.lon >= 120 && at.lon <= 135 && at.lat >= 30 && at.lat <= 45; + lon.classList.toggle("is-invalid", !ok); + lat.classList.toggle("is-invalid", !ok); + if (ok) place(i, at); + }; + lon.addEventListener("change", typed); + lat.addEventListener("change", typed); + const pick = createButton({ + label: "📍", + variant: "ghost", + size: "sm", + onClick: () => { + active = i; + drawTable(); + }, + }); + pick.title = pick.ariaLabel = t("D02_Pick_Point"); + pick.hidden = !editable; + const row = el("div", { + className: `d02-points__row${active === i ? " is-active" : ""}`, + children: [ + el("span", { className: "d02-points__name", text: pointName(i) }), + pick, + lon, + lat, + ], + }); + const color = YEAR_COLORS[Math.min(i, years().length - 1) % YEAR_COLORS.length]; + row.style.setProperty("--d02-year-color", color); + return row; + }); + table.replaceChildren(...rows); + const saved = view?.years.find((y) => y.start); + info.textContent = view?.epsg + ? `${t("D02_Saved_Crs")} EPSG:${view.epsg}` + + (saved?.start + ? ` · ${saved.year} X ${saved.start.x.toFixed(3)} Y ${saved.start.y.toFixed(3)}` + : "") + : t("D02_Not_Saved"); + } + + function paint(focus = ""): void { + drawTable(); + drawMap(focus); + } + + function adopt(next: PointsView): void { + view = next; + points = boundaryPoints(next); + } + + async function store(): Promise { + if (!view) return false; + showLoadingOverlay(); + try { + const body = { + years: years().map((year, i) => ({ year, start: points[i], end: points[i + 1] })), + }; + adopt( + await request(`/projects/${projectId}/plan/points`, { + method: "PUT", + body: JSON.stringify(body), + }), + ); + sessionStorage.removeItem(cacheKey(projectId)); + active = firstEmpty(); + paint(); + showToast(t("D02_Saved"), "success"); + return true; + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + return false; + } finally { + hideLoadingOverlay(); + } + } + + // [>] = 저장 성공 뒤 D03(지형 받기 시작은 D03 몫) · 실패면 머묾 + left.addEventListener("ui-step-nav", (event) => { + if ((event as CustomEvent).detail !== 1) return; + event.stopPropagation(); + if (!editable) return navigateTo(ROUTES.D03_PLAN_ANALYSIS); + void store().then((ok) => ok && navigateTo(ROUTES.D03_PLAN_ANALYSIS)); + }); + + try { + editable = !!(await fetchSessionUser())?.is_forest_service; + save.hidden = !editable; + if (!editable) { + const mapHint = map.root.querySelector("[data-kakao-map-hint]"); + pointsHint.textContent = t("D02_View_Only"); + if (mapHint) mapHint.textContent = t("D02_View_Only"); + } + adopt(await request(`/projects/${projectId}/plan/points`)); + const cached = JSON.parse(sessionStorage.getItem(cacheKey(projectId)) ?? "null") as { + points?: Array; + } | null; + if (editable && cached?.points?.length === points.length) points = cached.points; + active = firstEmpty(); + paint(); + } catch (error) { + table.replaceChildren( + el("p", { className: "d02-info", text: error instanceof Error ? error.message : "" }), + ); + } +} diff --git a/D02_PlanBase/D02_PlanBase_UI_Style.css b/D02_PlanBase/D02_PlanBase_UI_Style.css new file mode 100644 index 000000000..ff48d662e --- /dev/null +++ b/D02_PlanBase/D02_PlanBase_UI_Style.css @@ -0,0 +1,51 @@ +/* D02 기준 지정(529-3) — 메인 지도 = 본문 높이 가득 · 좌 연도 지점 표 */ +.d02-map { + height: calc(100dvh - var(--app-header-h)); +} + +.d02-map .ui-kakao-map, +.d02-map .ui-kakao-map__box { + height: 100%; +} + +.d02-points { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.d02-points__row { + display: grid; + grid-template-columns: auto 1fr 1fr; + gap: 4px; + align-items: center; + padding: 4px 6px; + border-left: 4px solid var(--d02-year-color); + border-radius: 4px; +} + +.d02-points__row.is-active { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.06)); +} + +.d02-points__name { + grid-column: 1 / -1; + font-size: var(--text-caption); + font-weight: 600; +} + +.d02-points__input { + width: 100%; + min-width: 0; + font-size: var(--text-caption); +} + +.d02-points__input.is-invalid { + outline: 1px solid var(--color-danger, #d92d20); +} + +.d02-info { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} diff --git a/D02_PlanBase/draft/D02_PlanBase_Route.ts b/D02_PlanBase/draft/D02_PlanBase_Route.ts new file mode 100644 index 000000000..36678ce0d --- /dev/null +++ b/D02_PlanBase/draft/D02_PlanBase_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * D02_PlanBase_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = D02 기준 지정(529-3) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D02_PLAN_BASE, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../D02_PlanBase_UI_Page")).renderD02PlanBase, +}; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py new file mode 100644 index 000000000..35d2509a6 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py @@ -0,0 +1,400 @@ +"""D 예정노선 2단계 분석 — 연도 구간마다 B05 노선 추천 엔진으로 지점을 이음 (요구 345 · PLAN 530-2). + +- 구간 = [P(i-1), P(i)] 한 번씩 `generate_initial_route` — 시종점을 옮기지 않아 연도 경계가 맞물림 +- 갈래 넷(`PLAN_MODES` · follow 뺌 — 두 점이면 직선) · 연도마다 고름 +- 추천 기준 = 프로젝트 임도 종류 · 설계속도 → B 법정 함수 · 지형 구분은 B 폴백(`TERRAIN_TYPE`) +- 계류 이격 = 도엽 하천선(B 로더 그대로) · 복도 반폭 = `PLAN_CORRIDOR_M`(D 만) +- 연도 경계는 꺾인 채(경계점 = 측점) · 누가거리 = 등록 시작 누가부터 고른 갈래 길이로 이어 셈 +- 파생 저장본 = `D03_PlanAnalysis/_derived/route_<연도>_<지점 열쇠>.json`(입력 지문 같으면 다시 + 안 셈) · 끈 지점 분석이 저장 지점 분석본을 덮지 않음 · 연도마다 최근 `KEEP_PER_YEAR` 개 + 저장 + 지점 것만 남김 · 옛 꼴 `route_<연도>.json` 은 읽기만(공유 storage — 옛 판 서버가 아직 읽음) + +계산 자리 = ③ 서버 단독(B 추천과 같음). + +⚠ 파이썬·TS 짝 — `pick_mode` · `chain` · `joined_line` · `with_edits` ↔ +`logic/D03_PlanAnalysis_Plan.ts` `pickMode` · `chainRows` · `joinedLine` · `withEdits`(화면이 고르는 +즉시) · 거울 시험 `test_530_3_plan_logic.py` · `test_543_plan_edit.py`. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import math +import multiprocessing +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path +from typing import Any, Callable + +import numpy as np +import shapely +from shapely.geometry import LineString, MultiLineString + +from B05_Profile.B05_Profile_Engine_Grade import ( + legal_grade_limit_pct, + legal_plan_radius_min_m, + resolve_design_speed, +) +from B05_Profile.B05_Profile_Engine_RouteInitial import ( + _POOLS, + _TERRAINS, + generate_initial_route, + load_terrain_and_streams, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Store import request_fingerprint +from common_util.common_util_json import atomic_write_json +from common_util.common_util_surface_confirmation import SHEET_ANALYSIS_FILTER +from config.config_system_terrain import PLAN_ANALYZE_WORKERS +from D03_PlanAnalysis.D03_PlanAnalysis_Terrain import PLAN_CORRIDOR_M, models_dir_of + +logger = logging.getLogger(__name__) + +PLAN_MODES = ("gradeline", "earthwork", "gentle", "weighted") +# 도엽 DEM(스무딩) — B 「수치지형도」 분석 지형과 같은 고르기 +SELECTION = {"source_filter": SHEET_ANALYSIS_FILTER, "method": "dtm", "smooth": True} +# D 등록에 지형 구분 칸 없음 — B `_road_settings` 못 읽을 때 폴백 그대로(가장 완화) +TERRAIN_TYPE = "special" +ALGO = 1 +# 계류 거르기 칸 · 지형 상자 여유(m) — `near_streams` +STREAM_TILE_M = 200.0 +STREAM_PAD_M = 100.0 +# 연도마다 남길 분석본 수 — 저장 지점 분석본은 따로 늘 남김 +KEEP_PER_YEAR = 3 + +Progress = Callable[[int, str], None] +Loader = Callable[..., tuple[Any, list]] + + +def derived_dir(project_root: Path) -> Path: + return project_root / "D03_PlanAnalysis" / "_derived" + + +def plan_criteria(road_type: str | None, design_speed_kph: int | None) -> dict[str, Any]: + """추천 기준 — 종단 상한(비율) · 최소 R · 설계속도(B 법정 함수).""" + grade_class = str(road_type or "work") + speed = resolve_design_speed(grade_class, design_speed_kph) + return { + "max_grade": legal_grade_limit_pct(grade_class, TERRAIN_TYPE, False, speed) / 100.0, + "min_radius_m": legal_plan_radius_min_m(speed, TERRAIN_TYPE), + "design_speed": speed, + } + + +def _point(value: Any) -> tuple[float, float]: + """엔진 출력 자리수(0.1 mm)로 — 노선 끝점 = 지점 그대로 · 연도 경계가 정확히 맞물림.""" + return (round(float(value[0]), 4), round(float(value[1]), 4)) + + +def _same_point(a: Any, b: Any) -> bool: + return abs(float(a[0]) - float(b[0])) < 1e-3 and abs(float(a[1]) - float(b[1])) < 1e-3 + + +def segment_path(project_root: Path, seg: dict[str, Any]) -> Path: + """연도 · 지점 열쇠 파일 — 지점이 다르면 다른 파일(덮지 않음).""" + key = hashlib.blake2b( + repr((_point(seg["start"]), _point(seg["end"]))).encode(), digest_size=4 + ).hexdigest() + return derived_dir(project_root) / f"route_{int(seg['year'])}_{key}.json" + + +def _find(project_root: Path, seg: dict[str, Any]) -> tuple[Path, dict[str, Any]] | None: + """지점이 지금과 같은 저장본(1 mm) — 열쇠 파일 · 같은 연도 다른 열쇠(새것부터) · 옛 꼴 차례.""" + year = int(seg["year"]) + folder = derived_dir(project_root) + exact = segment_path(project_root, seg) + others = sorted( + (p for p in folder.glob(f"route_{year}_*.json") if p != exact), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + for path in (exact, *others, folder / f"route_{year}.json"): + try: + stored = json.loads(path.read_text("utf-8")) + except (OSError, ValueError): + continue + if _same_point(stored["start"], seg["start"]) and _same_point(stored["end"], seg["end"]): + return path, stored + return None + + +def read_segment(project_root: Path, seg: dict[str, Any]) -> dict[str, Any] | None: + """저장본 — 지점이 지금과 같을 때만(입력 지문은 분석 때 다시 봄).""" + found = _find(project_root, seg) + return found[1] if found else None + + +def _prune(project_root: Path, seg: dict[str, Any]) -> None: + """연도 분석본 — 최근 `KEEP_PER_YEAR` 개 + 저장 지점(`saved` · 없으면 seg) 것만 남김 · 옛 꼴은 + 안 지움.""" + start, end = seg.get("saved") or (seg["start"], seg["end"]) + canon = _find(project_root, {"year": seg["year"], "start": start, "end": end}) + files = sorted( + derived_dir(project_root).glob(f"route_{int(seg['year'])}_*.json"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + for path in files[KEEP_PER_YEAR:]: + if canon is None or path != canon[0]: + path.unlink(missing_ok=True) + + +def _route_view(result: dict[str, Any]) -> dict[str, Any]: + keep = ("planned", "nodes", "curves", "metrics", "relaxed", "terrain_bound_pct") + return {key: result.get(key) for key in keep} + + +def _engine_inputs(criteria: dict[str, Any]) -> dict[str, Any]: + return { + "weights": None, + "max_grade": criteria["max_grade"], + "min_radius_m": criteria["min_radius_m"], + "stream_offset_m": None, + "grade_smooth_m": None, + "target_grade": None, + "with_profile": False, + "design_speed": criteria["design_speed"], + } + + +def segment_fingerprint( + seg: dict[str, Any], + terrain: Any, + streams: list, + criteria: dict[str, Any], + corridor_m: float = PLAN_CORRIDOR_M, +) -> str: + """구간 입력 지문 — 갈래 넷 B 저장 키 · 복도 · 판.""" + expected = [_point(seg["start"]), _point(seg["end"])] + inputs = _engine_inputs(criteria) + keys = [ + request_fingerprint(expected, terrain, streams, mode=mode, **inputs)[0] + for mode in PLAN_MODES + ] + return hashlib.blake2b( + repr((keys, float(corridor_m), ALGO)).encode(), digest_size=16 + ).hexdigest() + + +def analyze_segment( + project_root: Path, + seg: dict[str, Any], + terrain: Any, + streams: list, + criteria: dict[str, Any], + corridor_m: float = PLAN_CORRIDOR_M, +) -> dict[str, Any]: + """연도 구간 하나 · 갈래 넷 — 입력 지문이 저장본과 같으면 그대로(`stored` True).""" + expected = [_point(seg["start"]), _point(seg["end"])] + inputs = _engine_inputs(criteria) + fingerprint = segment_fingerprint(seg, terrain, streams, criteria, corridor_m) + stored = read_segment(project_root, seg) + if stored is not None and stored.get("fingerprint") == fingerprint: + return {**stored, "stored": True} + routes: dict[str, Any] = {} + for mode in PLAN_MODES: + try: + routes[mode] = _route_view( + generate_initial_route( + expected, terrain, streams, mode=mode, corridor_m=corridor_m, **inputs + ) + ) + except ValueError as error: # NoRoute 포함 — 그 갈래만 빔 + routes[mode] = {"error": str(error)} + result = { + "year": int(seg["year"]), + "start": list(expected[0]), + "end": list(expected[1]), + "fingerprint": fingerprint, + "corridor_m": float(corridor_m), + "criteria": { + "max_grade_pct": round(criteria["max_grade"] * 100.0, 2), + "min_radius_m": criteria["min_radius_m"], + "design_speed_kph": criteria["design_speed"], + "terrain_type": TERRAIN_TYPE, + }, + "routes": routes, + } + atomic_write_json(segment_path(project_root, seg), result) + _prune(project_root, seg) + return {**result, "stored": False} + + +def near_streams(terrain: Any, streams: list) -> list: + """지형 상자 안 어느 점에서든 가장 가까운 계류일 수 있는 선만 — 나머지는 엔진 값에 안 닿음. + + 칸마다 닿는 거리 = 칸 가운데 계류 거리 + 칸 반대각(거리는 1-립시츠) — 그 안에 드는 선만 남기면 + 상자 안 점의 계류 거리 · 건너기 · 격자 거리가 통째와 같음(통째 거리 = 추천 시간의 큰 몫). + """ + if not streams: + return streams + x0, x1 = float(np.min(terrain.x)) - STREAM_PAD_M, float(np.max(terrain.x)) + STREAM_PAD_M + y0, y1 = float(np.min(terrain.y)) - STREAM_PAD_M, float(np.max(terrain.y)) + STREAM_PAD_M + xs = np.linspace(x0, x1, max(1, math.ceil((x1 - x0) / STREAM_TILE_M)) + 1) + ys = np.linspace(y0, y1, max(1, math.ceil((y1 - y0) / STREAM_TILE_M)) + 1) + lo_x, lo_y = np.meshgrid(xs[:-1], ys[:-1]) + hi_x, hi_y = np.meshgrid(xs[1:], ys[1:]) + tiles = shapely.box(lo_x.ravel(), lo_y.ravel(), hi_x.ravel(), hi_y.ravel()) + reach = shapely.distance(shapely.centroid(tiles), MultiLineString(streams)) + 0.5 * math.hypot( + xs[1] - xs[0], ys[1] - ys[0] + ) + lines = np.array([LineString(line) for line in streams], dtype=object) + keep = (shapely.distance(lines[:, None], tiles[None, :]) <= reach[None, :]).any(axis=1) + return [line for line, kept in zip(streams, keep) if kept] + + +def _load(project_root: Path, year: int, loader: Loader) -> tuple[Any, list]: + terrain, streams = loader( + project_root, + SELECTION, + models_dir_of(project_root, year), + derived_dir(project_root) / f"cost_{year}", + ) + return terrain, near_streams(terrain, streams) + + +def _segment_job( + project_root: Path, + seg: dict[str, Any], + criteria: dict[str, Any], + corridor_m: float, + loader: Loader, +) -> dict[str, Any]: + """연도 구간 하나 — 지형 읽기부터(제 프로세스면 끝날 때 비용면 · 묶음 메모리를 놓음).""" + terrain, streams = _load(project_root, int(seg["year"]), loader) + return analyze_segment(project_root, seg, terrain, streams, criteria, corridor_m) + + +def _worker_job(*args: Any) -> dict[str, Any]: + """나란히 셈 프로세스 — 구간 하나 뒤 B 기억(비용면 · 묶음)을 비워 다음 구간 메모리를 놓음.""" + try: + return _segment_job(*args) + finally: + _POOLS.clear() + _TERRAINS.clear() + + +def analyze_plan( + project_root: Path, + segments: list[dict[str, Any]], + criteria: dict[str, Any], + *, + loader: Loader = load_terrain_and_streams, + corridor_m: float = PLAN_CORRIDOR_M, + on_progress: Progress | None = None, + workers: int = PLAN_ANALYZE_WORKERS, +) -> list[dict[str, Any]]: + """연도 구간마다(연도 DEM · 연도 비용면 캐시) `analyze_segment`. + + 다시 셀 구간이 둘 이상이면 프로세스 `workers` 개로 나란히 — 구간끼리 입력 · 저장본이 따로라 + 결과 같음. `workers` 1 = 이 프로세스(가짜 지형 시험).""" + + def report(percent: int, message: str) -> None: + if on_progress is not None: + on_progress(percent, message) + + results: list[dict[str, Any] | None] = [None] * len(segments) + todo = [] + for i, seg in enumerate(segments): + stored = read_segment(project_root, seg) + if stored is not None: # 저장본이 있을 때만 이 프로세스에서 지형을 읽어 지문을 봄 + terrain, streams = _load(project_root, int(seg["year"]), loader) + if stored.get("fingerprint") == segment_fingerprint( + seg, terrain, streams, criteria, corridor_m + ): + results[i] = {**stored, "stored": True} + continue + todo.append(i) + + count = min(workers, len(todo)) + if count <= 1: + for n, i in enumerate(todo): + report(100 * n // len(segments), f"{int(segments[i]['year'])}년 구간 노선 추천 중") + results[i] = _segment_job(project_root, segments[i], criteria, corridor_m, loader) + else: + years = "·".join(str(int(segments[i]["year"])) for i in todo) + report(0, f"{years}년 구간 노선 추천 중(나란히 {count})") + context = multiprocessing.get_context("spawn") + with ProcessPoolExecutor(count, mp_context=context) as pool: + futures = { + pool.submit(_worker_job, project_root, segments[i], criteria, corridor_m, loader): i + for i in todo + } + for n, future in enumerate(as_completed(futures), 1): + i = futures[future] + results[i] = future.result() + report( + 100 * n // len(todo), f"{int(segments[i]['year'])}년 구간 끝 ({n}/{len(todo)})" + ) + report(100, "분석 끝") + return results # type: ignore[return-value] + + +def pick_mode(result: dict[str, Any], wanted: str | None) -> str | None: + """고른 갈래 — 없거나 빈 갈래면 차례대로 첫 성공 갈래.""" + routes = result.get("routes") or {} + if wanted in routes and "error" not in routes[wanted]: + return wanted + return next((m for m in PLAN_MODES if m in routes and "error" not in routes[m]), None) + + +def chain( + results: list[dict[str, Any]], choices: dict[int, str | None], start_chainage_m: float +) -> list[dict[str, Any]]: + """연도별 고른 갈래 · 시작 · 끝 누가(이어 셈) — 앞 연도 끝 누가 = 다음 연도 시작 누가.""" + at = float(start_chainage_m or 0.0) + rows = [] + for result in results: + mode = pick_mode(result, choices.get(int(result["year"]))) + length = float(result["routes"][mode]["metrics"]["length_m"]) if mode else 0.0 + rows.append( + { + "year": int(result["year"]), + "mode": mode, + "length_m": round(length, 3), + "start_chainage_m": round(at, 3), + "end_chainage_m": round(at + length, 3), + } + ) + at += length + return rows + + +def line_length(line: list) -> float: + """선 길이(m) — 짝: `lineLength`(같은 차례로 더함).""" + total = 0.0 + for a, b in zip(line, line[1:]): + total += math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1])) + return total + + +def with_edits( + results: list[dict[str, Any]], edits: dict[int, dict[str, Any]] +) -> list[dict[str, Any]]: + """고친 노선(`{mode, planned, metrics?}`)을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 + 길이. 그 갈래가 없거나 빈 갈래면 그대로.""" + out = [] + for result in results: + edit = edits.get(int(result["year"])) + route = (result.get("routes") or {}).get(edit["mode"]) if edit else None + if not edit or route is None or "error" in route: + out.append(result) + continue + metrics = {**(edit.get("metrics") or {}), "length_m": line_length(edit["planned"])} + routes = { + **result["routes"], + edit["mode"]: {**route, "planned": edit["planned"], "metrics": metrics, "edited": True}, + } + out.append({**result, "routes": routes}) + return out + + +def joined_line(results: list[dict[str, Any]], rows: list[dict[str, Any]]) -> list[list[float]]: + """고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채).""" + line: list[list[float]] = [] + for result, row in zip(results, rows): + if row["mode"] is None: + continue + planned = result["routes"][row["mode"]]["planned"] + line.extend(planned[1:] if line and _same_point(line[-1], planned[0]) else planned) + return line diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts new file mode 100644 index 000000000..6284663e6 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -0,0 +1,127 @@ +/* ============================================================================= + * D03_PlanAnalysis_Api.ts + * D03 분석 화면 서버 요청(PLAN 530-3) — 지점 정본(D02) · 지형 받기 · 분석 · 진행 · 저장본 · [저장] · + * 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄 · 꺾임점 고침은 + * `edits`(마디 · 켬끔 · R — 선은 안 보냄 · 서버가 다시 그음). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; +import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler"; +import type { PlanEdit } from "./logic/D03_PlanAnalysis_Edit"; +import type { ChainRow, RouteMetrics, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan"; + +/** 저장된 꺾임점 고침 — 입력 + 그때 서버가 잰 값. */ +export type SavedEdit = PlanEdit & { metrics?: RouteMetrics | null }; + +export interface PlanPointsView { + year_start: number; + year_end: number; + epsg: number | null; + years: Array<{ + year: number; + start: { x: number; y: number } | null; + end: { x: number; y: number } | null; + }>; +} + +export interface PlanJob { + running: boolean; + percent: number; + message: string | null; + error: string | null; +} + +export interface PlanAnalysisView { + years: Array<{ + year: number; + start: Vertex; + end: Vertex; + analysis: YearAnalysis | null; + /** 저장된 꺾임점 고침(분석 지문이 같을 때만). */ + edit?: SavedEdit | null; + }>; + chain: ChainRow[] | null; + /** 등록 노선 시작 누가(m). */ + start_chainage_m: number; + /** 등록 노선 종료 누가(계획 목표 · m) — 없으면 null. */ + end_chainage_target_m?: number | null; +} + +export type PlanPoints = Array<{ year: number; start: Vertex; end: Vertex }>; + +async function request(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + credentials: "include", + headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`; + throw new Error(detail); + } + return body; +} + +const base = (projectId: string): string => `/projects/${projectId}/plan`; +const pointsBody = (points: PlanPoints | null): string => JSON.stringify(points ? { points } : {}); + +export const fetchPlanPoints = (projectId: string): Promise => + request(`${base(projectId)}/points`); + +export const startPlanJob = ( + projectId: string, + kind: "terrain" | "analyze", + points: PlanPoints | null, +): Promise => + request(`${base(projectId)}/${kind}`, { method: "POST", body: pointsBody(points) }); + +export const fetchPlanProgress = ( + projectId: string, +): Promise<{ terrain: PlanJob; analyze: PlanJob }> => request(`${base(projectId)}/progress`); + +export const fetchPlanAnalysis = ( + projectId: string, + points: PlanPoints | null, +): Promise => + request( + `${base(projectId)}/analysis` + + (points ? `?points=${encodeURIComponent(JSON.stringify(points))}` : ""), + ); + +export const savePlanAnalysis = ( + projectId: string, + choices: Record, + points: PlanPoints | null, + edits: Record = {}, +): Promise<{ chain: ChainRow[] }> => + request(`${base(projectId)}/analysis`, { + method: "PUT", + body: JSON.stringify(points ? { choices, points, edits } : { choices, edits }), + }); + +export interface YearSurface { + modelId: number; + grid: DtmGrid; +} + +/** 연도별 지형면 — 모델 행 경로 `…/models_<연도>/…` · 격자 = 분석과 같은 평활본. */ +export async function fetchYearSurfaces(projectId: string): Promise> { + const { models } = await listSurfaceModels(projectId); + const out = new Map(); + for (const model of models) { + const year = Number(/models_(\d+)\//.exec(model.model_file_path ?? "")?.[1]); + if (!year || out.has(year)) continue; + const stamp = String(model.generation_params?.file_stamp ?? ""); + const url = + `${API_BASE_URL}/projects/${projectId}/surface/models/${model.id}/grid` + + `?smooth=true&sig=${encodeURIComponent(stamp)}`; + out.set(year, { + modelId: model.id, + grid: parseDtmGrid(await fetchCachedBytes(projectId, url)), + }); + } + return out; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Edit.py b/D03_PlanAnalysis/D03_PlanAnalysis_Edit.py new file mode 100644 index 000000000..33daee3b5 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Edit.py @@ -0,0 +1,169 @@ +"""D 예정노선 꺾임점 고침 [저장] 정본(요구 363-1 · PLAN 543-3). + +- 받는 것 = 연도별 마디 · 곡선 켬끔 · R(화면이 그은 선은 안 받음) +- 선 = B05 편집 갈래 `node_polyline`(Node `route.polyline` = 화면 `buildEditedPolyline` 같은 TS) · + 못 돌림 = 파이썬 짝 `build_planned_polyline(simplify=False)` +- 지표 = 엔진과 같은 `route_metrics` → `hard_limits` → `with_station_misses`(연도 지형 · 계류) +- 처음 · 끝 마디 = 연도 경계 지점(1 mm) · 고친 갈래 · 분석 지문이 지금과 같아야 함 + +계산 자리 = ① 파이썬 · TS 짝 — 화면 `logic/D03_PlanAnalysis_Edit.ts`(조작 중 즉시) ↔ +여기([저장] 정본) · 거울 시험 `resources/tester/test_543_plan_edit.py`. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Engine_RouteInitial import ( + ROAD_WIDTH_M, + STREAM_OFFSET_M, + load_terrain_and_streams, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import route_metrics +from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import hard_limits +from B05_Profile.B05_Profile_Engine_RouteInitial_Station import with_station_misses +from B05_Profile.B05_Profile_Route_Polyline_Write import node_polyline +from common_util.common_util_route_polyline import PlannedPolyline, build_planned_polyline +from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze + +# 끝 마디 = 지점 판정(m) — 짝: `D02_PlanBase_Router.SAME_M` +SAME_M = 1e-3 +INPUT_KEYS = ("base_mode", "base_fingerprint", "nodes", "curve_flags", "radii") + + +class EditError(ValueError): + """고침을 받을 수 없음 — 까닭 그대로 화면에.""" + + +def edited_line(edit: dict[str, Any], min_radius_m: float) -> PlannedPolyline: + """마디 · 켬끔 · R → 선(화면과 같은 편집 갈래).""" + points = [(float(x), float(y)) for x, y in edit["nodes"]] + flags = [bool(on) for on in edit["curve_flags"]] + radii = [None if r is None else float(r) for r in edit["radii"]] + built = node_polyline(points, min_radius_m, flags, radii, None, None) + if built is not None: + return built[0] + return build_planned_polyline( + points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii + ) + + +def check_edit(edit: dict[str, Any], result: dict[str, Any], mode: str) -> None: + """고친 갈래 · 분석 지문 · 칸 수 · 끝 마디 = 지점.""" + year = int(result["year"]) + if edit["base_mode"] != mode: + raise EditError(f"{year}년 고침이 고른 갈래와 다릅니다.") + if edit["base_fingerprint"] != result.get("fingerprint"): + raise EditError(f"{year}년 분석이 바뀌어 고침이 낡았습니다 — 다시 고치세요.") + nodes = edit["nodes"] + if len(nodes) < 2 or not (len(nodes) == len(edit["curve_flags"]) == len(edit["radii"])): + raise EditError(f"{year}년 고침 마디 꼴이 다릅니다.") + if any(r is not None and not float(r) > 0 for r in edit["radii"]): + raise EditError(f"{year}년 R 은 0 보다 커야 합니다.") + for got, want in ((nodes[0], result["start"]), (nodes[-1], result["end"])): + if max(abs(float(a) - float(b)) for a, b in zip(got, want)) > SAME_M: + raise EditError(f"{year}년 처음 · 끝 마디가 연도 경계 지점과 다릅니다.") + + +def edit_metrics( + line: PlannedPolyline, result: dict[str, Any], terrain: Any, streams: list +) -> dict[str, Any]: + """엔진과 같은 잼 — 원지반 기울기는 참고(못 지킨 수에서 뺌) · 측점 밖 곡선 더함.""" + criteria = result["criteria"] + metrics = route_metrics( + line.vertices, + [tuple(result["start"]), tuple(result["end"])], + terrain, + streams, + line.curves, + max_grade=float(criteria["max_grade_pct"]) / 100.0, + min_radius_m=float(criteria["min_radius_m"]), + stream_offset_m=STREAM_OFFSET_M, + road_width_m=ROAD_WIDTH_M, + bound_pct=None, + ) + return with_station_misses(hard_limits(metrics), line) + + +def saved_edit(route_json: Any) -> dict[str, Any] | None: + """저장된 고침(route_json `edit` + 그때 선 · 지표) — 없으면 None.""" + try: + value = json.loads(route_json) if isinstance(route_json, str) else route_json + except ValueError: + return None + edit = value.get("edit") if isinstance(value, dict) else None + if not isinstance(edit, dict) or not all(key in edit for key in INPUT_KEYS): + return None + return { + **{key: edit[key] for key in INPUT_KEYS}, + "planned": value.get("planned"), + "nodes_info": value.get("nodes"), + "curves": value.get("curves"), + "metrics": value.get("metrics"), + } + + +def same_inputs(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool: + """같은 고침 입력인가(1 mm) — 같으면 저장된 선 · 지표를 다시 씀.""" + if not a or not b: + return False + + def near(x: Any, y: Any) -> bool: + return ( + x is None + and y is None + or (x is not None and y is not None and abs(float(x) - float(y)) < 1e-3) + ) + + return ( + a["base_mode"] == b["base_mode"] + and a["base_fingerprint"] == b["base_fingerprint"] + and len(a["nodes"]) == len(b["nodes"]) + and all(near(p[0], q[0]) and near(p[1], q[1]) for p, q in zip(a["nodes"], b["nodes"])) + and [bool(v) for v in a["curve_flags"]] == [bool(v) for v in b["curve_flags"]] + and all(near(x, y) for x, y in zip(a["radii"], b["radii"])) + ) + + +def build_edits( + project_root: Path, + results: list[dict[str, Any]], + rows: list[dict[str, Any]], + edits: dict[int, dict[str, Any]], + saved: dict[int, dict[str, Any] | None], + loader: analyze.Loader = load_terrain_and_streams, +) -> dict[int, dict[str, Any]]: + """연도 → 고친 노선 `{mode, planned, nodes, curves, metrics, edit}` — 저장된 고침과 입력이 + 같으면 그 선 · 지표 그대로(지형 안 읽음).""" + out: dict[int, dict[str, Any]] = {} + for result, row in zip(results, rows): + year = int(result["year"]) + edit = edits.get(year) + if edit is None: + continue + check_edit(edit, result, row["mode"]) + inputs = {key: edit[key] for key in INPUT_KEYS} + old = saved.get(year) + if old is not None and old.get("metrics") and same_inputs(inputs, old): + out[year] = { + "mode": row["mode"], + "planned": old["planned"], + "nodes": old["nodes_info"], + "curves": old["curves"], + "metrics": old["metrics"], + "edit": inputs, + } + continue + line = edited_line(inputs, float(result["criteria"]["min_radius_m"])) + terrain, streams = analyze._load(project_root, year, loader) + out[year] = { + "mode": row["mode"], + "planned": [[round(x, 4), round(y, 4)] for x, y in line.vertices], + "nodes": [node.as_dict() for node in line.nodes], + "curves": [curve.as_dict() for curve in line.curves], + "metrics": edit_metrics(line, result, terrain, streams), + "edit": inputs, + } + return out diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py new file mode 100644 index 000000000..94fbe4563 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -0,0 +1,415 @@ +"""D 예정노선 2단계 분석 API (요구 345 · PLAN 530-1 · 530-2). + +- `POST /api/projects/{id}/plan/terrain` — 지형 받기(뒤에서) · 끝나면 연도별 `surface_models` 행 +- `POST …/plan/analyze` — 연도 구간마다 노선 추천 갈래 넷(뒤에서 · 파생 저장본) +- `GET …/plan/progress` — 두 일의 진행률 +- `GET …/plan/analysis` — 저장본 + 고른 갈래 · 이은 누가 · 이은 중심선 +- `PUT …/plan/analysis` — [저장] 연도별 고른 갈래(+ 꺾임점 고침) → `plan_route_years.route_json` · + `fingerprint` + +지점 정본은 `plan_route_years` 시점 · 종점 칸(529-3 몫). D03 화면에서 끈 지점(캐시 · PLAN 530-3)은 +`points`(작업 좌표계 m)로 받아 그 지점으로 받기 · 분석 · 보기 · [저장] 때만 D02 규칙으로 정본에 씀. +계산 자리 = ③ 서버 단독 — [저장]은 노선을 받지 않고 갈래 이름만 받아 서버 저장본으로 다시 셈. +꺾임점 고침(543)은 마디 · 켬끔 · R 만 받아 서버가 선 · 지표를 다시 셈(`D03_PlanAnalysis_Edit`) · +GET 은 분석 지문이 같은 저장 고침만 돌려줌. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path +from typing import Any +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_auth import is_forest_service, require_company +from common_util.common_util_crs import tm_to_lonlat +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool +from D02_PlanBase import D02_PlanBase_Router as base +from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze +from D03_PlanAnalysis import D03_PlanAnalysis_Edit as plan_edit +from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["D03 Plan Analysis"]) + +# 진행 — (프로젝트, 일) → {running, percent, message, error} · 프로세스 메모리 +_JOBS: dict[tuple[str, str], dict[str, Any]] = {} +_TASKS: set[asyncio.Task] = set() +KINDS = ("terrain", "analyze") + + +class PlanPoint(BaseModel): + """끈 지점(작업 좌표계 m) — 연도 하나의 시점 · 종점.""" + + year: int + start: tuple[float, float] + end: tuple[float, float] + + +class PointsBody(BaseModel): + points: list[PlanPoint] | None = None + + +class PlanEditBody(BaseModel): + """연도 하나의 꺾임점 고침 — 고친 갈래 · 그 분석 지문 · 마디(작업 좌표계 m) · 곡선 켬끔 · R.""" + + base_mode: str + base_fingerprint: str + nodes: list[tuple[float, float]] + curve_flags: list[bool] + radii: list[float | None] + + +class ChoiceRequest(PointsBody): + choices: dict[int, str] # 연도 → 갈래 + edits: dict[int, PlanEditBody] | None = None # 연도 → 꺾임점 고침(없는 연도 = 엔진 노선) + + +def _override(segments: list[dict[str, Any]], points: list[PlanPoint]) -> list[dict[str, Any]]: + """연도 구간 지점을 끈 지점으로 — 연도 같음 · 직전 종점 = 다음 시점(1 mm) 아니면 거절.""" + by_year = {p.year: p for p in points} + if set(by_year) != {seg["year"] for seg in segments}: + raise HTTPException(status_code=400, detail="끈 지점의 연도가 사업연도와 다릅니다.") + out = [ + { + **seg, + "start": tuple(by_year[seg["year"]].start), + "end": tuple(by_year[seg["year"]].end), + "saved": (seg["start"], seg["end"]), # 저장 지점 — 그 분석본은 쌓임 거르기에서 남김 + } + for seg in segments + ] + for prev, seg in zip(out, out[1:]): + if max(abs(a - b) for a, b in zip(prev["end"], seg["start"])) > base.SAME_M: + raise HTTPException( + status_code=400, detail=f"{seg['year']}년 시점이 직전 종점과 다릅니다." + ) + return out + + +async def _plan_inputs( + project_id: UUID, points: list[PlanPoint] | None = None +) -> tuple[Path, dict[str, Any], list[dict[str, Any]]]: + """(실경로, 프로젝트 칸, 연도 구간) — 빈 연도 · 좌표계 섞임 거절 · `points` = 끈 지점.""" + async with get_db_pool().acquire() as connection: + stored = await get_project_storage_relative_path(connection, project_id) + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT road_type, design_speed_kph, route_start_m, route_end_m FROM projects" + " WHERE id = %s", + (str(project_id),), + ) + project = await cursor.fetchone() + await cursor.execute( + "SELECT year, start_x, start_y, end_x, end_y, epsg, route_json, fingerprint " + "FROM plan_route_years WHERE project_id = %s ORDER BY year", + (str(project_id),), + ) + rows = await cursor.fetchall() + if not stored or not project: + raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.") + if not rows: + raise HTTPException( + status_code=400, detail="연도별 지점이 없습니다 — 1단계에서 지정하세요." + ) + for row in rows: + if any(row[k] is None for k in ("start_x", "start_y", "end_x", "end_y", "epsg")): + raise HTTPException(status_code=400, detail=f"{row['year']}년 지점이 비었습니다.") + if len({int(row["epsg"]) for row in rows}) != 1: + raise HTTPException(status_code=400, detail="연도마다 좌표계가 다릅니다.") + segments = [ + { + "year": int(row["year"]), + "start": (float(row["start_x"]), float(row["start_y"])), + "end": (float(row["end_x"]), float(row["end_y"])), + "epsg": int(row["epsg"]), + "route_json": row["route_json"], + } + for row in rows + ] + if points: + segments = _override(segments, points) + return Path(resolve_stored_project_path(stored)), project, segments + + +def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]: + key = (str(project_id), kind) + if (_JOBS.get(key) or {}).get("running"): + raise HTTPException(status_code=409, detail="이미 돌고 있습니다.") + job = {"running": True, "percent": 0, "message": "시작", "error": None} + _JOBS[key] = job + + def report(percent: int, message: str) -> None: + job.update(percent=int(percent), message=message) + + async def run() -> None: + try: + await work(report) + job.update(percent=100) + except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림 + logger.exception("D %s 실패: project_id=%s", kind, project_id) + job["error"] = str(getattr(error, "detail", None) or error) + finally: + job["running"] = False + + task = asyncio.create_task(run(), name=f"D {kind} {project_id}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return {"status": "accepted", **job} + + +async def _save_models(project_id: UUID, years: dict[int, list[dict[str, Any]]], epsg: int) -> None: + """연도별 지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로).""" + from B04_PreProcess.B04_PreProcess_Repository import _existing_models, _upsert_surface_model + + async with get_db_pool().acquire() as connection: + await connection.begin() + try: + existing = await _existing_models(connection, project_id) + for models in years.values(): + for model in models: + await _upsert_surface_model( + connection, + existing, + project_id=project_id, + model=model, + source_file_id=None, + processed_cloud_id=None, + crs_epsg=epsg, + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + +@router.post("/{project_id}/plan/terrain", response_model=None) +async def plan_terrain(project_id: UUID, body: PointsBody | None = None) -> dict[str, Any]: + """지점 기준 수치지형도 · 위성 · 연도별 DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`.""" + root, _, segments = await _plan_inputs(project_id, body.points if body else None) + epsg = segments[0]["epsg"] + + async def work(report: Any) -> None: + got = await asyncio.to_thread(terrain.ensure_plan_terrain, root, segments, epsg, report) + await _save_models(project_id, got["years"], epsg) + report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매") + + return _start_job(project_id, "terrain", work) + + +@router.post("/{project_id}/plan/analyze", response_model=None) +async def plan_analyze(project_id: UUID, body: PointsBody | None = None) -> dict[str, Any]: + """연도 구간마다 노선 추천 갈래 넷(뒤에서 · 같은 입력이면 저장본).""" + root, project, segments = await _plan_inputs(project_id, body.points if body else None) + criteria = analyze.plan_criteria(project["road_type"], project["design_speed_kph"]) + missing = [s["year"] for s in segments if not terrain.models_dir_of(root, s["year"]).is_dir()] + if missing: + raise HTTPException(status_code=409, detail=f"지형을 먼저 받으세요: {missing}년") + + async def work(report: Any) -> None: + await asyncio.to_thread( + lambda: analyze.analyze_plan(root, segments, criteria, on_progress=report) + ) + + return _start_job(project_id, "analyze", work) + + +@router.get("/{project_id}/plan/progress", response_model=None) +async def plan_progress(project_id: UUID) -> dict[str, Any]: + idle = {"running": False, "percent": 0, "message": None, "error": None} + return { + "status": "success", + **{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS}, + } + + +def _saved_mode(route_json: Any) -> str | None: + try: + value = json.loads(route_json) if isinstance(route_json, str) else route_json + except ValueError: + return None + return value.get("mode") if isinstance(value, dict) else None + + +def _assemble( + root: Path, + project: dict[str, Any], + segments: list[dict[str, Any]], + choices: dict[int, Any], + edits: dict[int, dict[str, Any]] | None = None, +) -> tuple[list[dict[str, Any] | None], list[dict[str, Any]] | None, list[list[float]]]: + """(연도 저장본 · 이은 누가 · 이은 중심선) — 저장본이 하나라도 없으면 None · 빈 선. + + `edits` = 연도 → 고친 노선(`{mode, planned, metrics}`) — 누가 · 선만 고친 길이로(저장본은 엔진 + 그대로). + """ + results = [analyze.read_segment(root, seg) for seg in segments] + if any(result is None for result in results): + return results, None, [] + view = analyze.with_edits(results, edits or {}) # type: ignore[arg-type] + rows = analyze.chain(view, choices, float(project["route_start_m"] or 0.0)) + return results, rows, analyze.joined_line(view, rows) + + +def _saved_edits( + root: Path, segments: list[dict[str, Any]], choices: dict[int, Any] +) -> dict[int, dict[str, Any]]: + """저장된 꺾임점 고침 — 저장 갈래 그대로 · 분석 지문이 지금과 같은 것만.""" + out = {} + for seg in segments: + saved = plan_edit.saved_edit(seg["route_json"]) + result = analyze.read_segment(root, seg) + if ( + saved + and result + and saved["base_mode"] == choices.get(seg["year"]) + and saved["base_fingerprint"] == result.get("fingerprint") + and saved.get("planned") + ): + out[seg["year"]] = saved + return out + + +def _edit_view(saved: dict[str, Any]) -> dict[str, Any]: + return {key: saved[key] for key in (*plan_edit.INPUT_KEYS, "metrics")} + + +def _query_points(points: str | None) -> list[PlanPoint] | None: + if not points: + return None + try: + return [PlanPoint(**item) for item in json.loads(points)] + except (ValueError, TypeError) as error: + raise HTTPException(status_code=400, detail="points 꼴이 다릅니다.") from error + + +@router.get("/{project_id}/plan/analysis", response_model=None) +async def plan_analysis(project_id: UUID, points: str | None = None) -> dict[str, Any]: + """분석 저장본(지점이 지금과 같은 것만) + 고른 갈래(저장값 · 없으면 첫 성공 갈래). + + `points` = 끈 지점 JSON(`[{year, start, end}]` · 작업 좌표계 m) — 그 지점의 저장본을 봄. + """ + root, project, segments = await _plan_inputs(project_id, _query_points(points)) + choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments} + saved = await asyncio.to_thread(_saved_edits, root, segments, choices) + edits = {year: {"mode": e["base_mode"], **e} for year, e in saved.items()} + results, rows, line = await asyncio.to_thread( + _assemble, root, project, segments, choices, edits + ) + return { + "status": "success", + "years": [ + { + "year": seg["year"], + "start": seg["start"], + "end": seg["end"], + "analysis": result, + # 저장된 꺾임점 고침(입력 + 그때 지표) — 분석 지문이 같을 때만 + "edit": _edit_view(saved[seg["year"]]) if seg["year"] in saved else None, + } + for seg, result in zip(segments, results) + ], + "chain": rows, + "line": line, + "start_chainage_m": float(project["route_start_m"] or 0.0), + # 등록 목표 끝 누가(요구서 3-1) — 화면이 이은 끝 누가와 차이를 보임 + "end_chainage_target_m": ( + float(project["route_end_m"]) if project["route_end_m"] is not None else None + ), + } + + +async def _store_points(project_id: UUID, segments: list[dict[str, Any]]) -> None: + """끈 지점 → D02 정본 규칙(경위도 · 잇기 · 바뀐 연도만 · 그 연도 노선 비움).""" + epsg = segments[0]["epsg"] + + def lonlat(xy: tuple[float, float]) -> base.LonLat: + lon, lat = tm_to_lonlat(xy[0], xy[1], epsg) + return base.LonLat(lon=lon, lat=lat) + + project, old = await base._load(project_id) + start = int(project["project_year"]) + end = int(project["project_year_end"] or start) + items = [ + base.YearPoints(year=s["year"], start=lonlat(s["start"]), end=lonlat(s["end"])) + for s in segments + ] + new_epsg, rows = base.with_tm(base.chain_years(start, end, items)) + if new_epsg != epsg: + raise HTTPException( + status_code=409, + detail="끈 지점이 다른 좌표계 띠로 넘어갔습니다 — 1단계에서 지정하세요.", + ) + writes = [row for row in rows if base.changed(row, old.get(row["year"]), epsg)] + if writes: + await base._store(project_id, epsg, writes, []) + + +@router.put("/{project_id}/plan/analysis", response_model=None) +async def save_plan_analysis( + project_id: UUID, + request: ChoiceRequest, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """[저장] — 끈 지점(있으면 정본으로) · 연도별 고른 갈래를 서버 저장본으로 다시 이어 씀.""" + if (request.points or request.edits) and not is_forest_service(session): + raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.") + root, project, segments = await _plan_inputs(project_id, request.points) + bad = [m for m in request.choices.values() if m not in analyze.PLAN_MODES] + if bad: + raise HTTPException(status_code=400, detail=f"모르는 갈래: {bad}") + choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments} + choices.update(request.choices) + results, rows, _ = await asyncio.to_thread(_assemble, root, project, segments, choices) + if rows is None: + raise HTTPException( + status_code=409, detail="분석 저장본이 없습니다 — [분석]을 먼저 하세요." + ) + lost = [r["year"] for r in rows if choices.get(r["year"]) not in (None, r["mode"])] + if lost: + raise HTTPException(status_code=409, detail=f"고른 갈래에 노선이 없습니다: {lost}년") + edited: dict[int, dict[str, Any]] = {} + if request.edits: + saved = {seg["year"]: plan_edit.saved_edit(seg["route_json"]) for seg in segments} + asked = {year: body.model_dump() for year, body in request.edits.items()} + try: + edited = await asyncio.to_thread( + plan_edit.build_edits, root, results, rows, asked, saved + ) + except plan_edit.EditError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + _, rows, _ = await asyncio.to_thread(_assemble, root, project, segments, choices, edited) + if request.points: + await _store_points(project_id, segments) + async with get_db_pool().acquire() as connection: + await connection.begin() + try: + async with connection.cursor() as cursor: + for result, row in zip(results, rows): + route = edited.get(row["year"]) or result["routes"][row["mode"]] + body = {**row, **{k: route[k] for k in ("planned", "nodes", "curves")}} + if row["year"] in edited: # 꺾임점 고침 — 입력 · 지표 함께(GET 이 돌려줌) + body.update(metrics=route["metrics"], edit=route["edit"]) + await cursor.execute( + "UPDATE plan_route_years SET route_json = %s, fingerprint = %s " + "WHERE project_id = %s AND year = %s", + ( + json.dumps(body, ensure_ascii=False), + result["fingerprint"], + str(project_id), + row["year"], + ), + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + return {"status": "success", "chain": rows} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py new file mode 100644 index 000000000..7d3be6c57 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -0,0 +1,221 @@ +"""D 예정노선 지형 받기 — 지점 기준 수치지형도 · 위성 · 연도 구간별 DEM (요구 345 · PLAN 530-1). + +지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0): + +- 도엽 = 모든 지점 + 구간 표본(`SAMPLE_STEP_M`) 각 3×3 → `ensure_sheets`(없는 것만 · prune 없음) +- 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀) +- 위성 = 기준 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀) +- DEM = 연도 구간마다 `models_<연도>/`(긴 노선 메모리 · 비용면 한도) · 범위 = 구간 상자 + 탐색 복도 +- 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음) + +계산 자리 = ③ 서버 단독. DB 행(`surface_models`)은 라우터가 씀. +""" + +from __future__ import annotations + +import json +import logging +import math +from pathlib import Path +from typing import Any, Callable + +import numpy as np + +from B04_PreProcess.B04_PreProcess_Engine_MapSheet import ( + neighbors_for_points, + sheet5k_to_bounds, + sheets_for_points, +) +from B04_PreProcess.B04_PreProcess_Engine_SheetParser import parse_and_merge_sheets +from B04_PreProcess.B04_PreProcess_Engine_SheetStore import ( + ensure_sheets, + get_project_map_sheets_dir, + get_sheet_path, +) +from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import build_sheet_surface_model +from B04_PreProcess.B04_PreProcess_Engine_VWorld import download_vworld_satellite_map +from common_util.common_util_json import atomic_write_json +from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD, SHEET_SURFACE_MARGIN_M + +logger = logging.getLogger(__name__) + +# 구간 표본 간격 — 도엽(≈2.8 × 2.3 km) 3×3 묶음이 끊기지 않게 +SAMPLE_STEP_M = 1000.0 +# 탐색 복도 반폭(D 만 · B 기본 100 m) — 실측(530-2): 100 · 200 m 는 못 지킨 곳 남음 · 300 m 0~1 +PLAN_CORRIDOR_M = 300.0 +# DEM 이 복도 밖으로 더 덮는 폭(복도 끝 칸 기울기 · 횡단 표본 ±20 m) +DEM_EXTRA_M = 50.0 +STAGE = Path("B04_PreProcess") +PRJ_NAME = "plan.prj" + +Progress = Callable[[int, str], None] + + +def models_dir_of(project_root: Path, year: int) -> Path: + """연도 구간 DEM 자리.""" + return project_root / STAGE / f"models_{int(year)}" + + +def segment_samples( + start: tuple[float, float], end: tuple[float, float] +) -> list[tuple[float, float]]: + """구간 위 표본(끝점 둘 포함 · `SAMPLE_STEP_M` 간격 이하).""" + length = math.hypot(end[0] - start[0], end[1] - start[1]) + count = max(1, math.ceil(length / SAMPLE_STEP_M)) + return [ + (start[0] + (end[0] - start[0]) * i / count, start[1] + (end[1] - start[1]) * i / count) + for i in range(count + 1) + ] + + +def reference_points(segments: list[dict[str, Any]], epsg: int) -> list[tuple[float, float]]: + """도엽 기준 좌표(위도, 경도) — 모든 지점 + 구간 표본.""" + from pyproj import Transformer + + to_wgs = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True) + points: list[tuple[float, float]] = [] + for seg in segments: + for x, y in segment_samples(seg["start"], seg["end"]): + lon, lat = to_wgs.transform(x, y) + points.append((lat, lon)) + return points + + +def write_plan_prj(project_root: Path, epsg: int) -> Path: + """작업 좌표계 .prj — 내용이 같으면 그대로.""" + from pyproj import CRS + + path = project_root / "B03_FileInput" / "input" / "prj" / PRJ_NAME + text = CRS.from_epsg(int(epsg)).to_wkt("WKT1_ESRI") + if not path.is_file() or path.read_text(encoding="utf-8", errors="ignore") != text: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def dem_outline(start: tuple[float, float], end: tuple[float, float]) -> np.ndarray: + """DEM 상자 꼭짓점 둘 — B 여유(`SHEET_SURFACE_MARGIN_M`)를 더하면 복도 + `DEM_EXTRA_M`.""" + pad = max(0.0, PLAN_CORRIDOR_M + DEM_EXTRA_M - SHEET_SURFACE_MARGIN_M) + xs, ys = (start[0], end[0]), (start[1], end[1]) + return np.array( + [[min(xs) - pad, min(ys) - pad], [max(xs) + pad, max(ys) + pad]], dtype=np.float64 + ) + + +def _read_json(path: Path) -> dict[str, Any] | None: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[str]: + """병합 = 지난번 병합 도엽(zip 남은 것) ∪ 이번 도엽 · 열쇠 같으면 건너뜀.""" + key_path = processed / "sheet_merge.key.json" + old = (_read_json(key_path) or {}).get("sheets") or [] + sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)}) + key = {"sheets": sheets} + if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))): + parse_and_merge_sheets(store, sheets, processed) + atomic_write_json(key_path, key) + return sheets + + +def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], epsg: int) -> str: + """위성 = 기준 도엽 도곽 ∪ 옛 범위 · 이미 덮으면 건너뜀. 돌려줌 = skip · done · failed.""" + from pyproj import Transformer + + to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True) + xs: list[float] = [] + ys: list[float] = [] + for sheet in sheets_for_points(points): + lon0, lat0, lon1, lat1 = sheet5k_to_bounds(sheet) + for lon, lat in ((lon0, lat0), (lon0, lat1), (lon1, lat0), (lon1, lat1)): + x, y = to_metric.transform(lon, lat) + xs.append(x) + ys.append(y) + need = {"x": [min(xs), max(xs)], "y": [min(ys), max(ys)]} + meta = _read_json(processed / "vworld_satellite_meta.json") + try: + old = [float(meta[k]) for k in ("x_min", "x_max", "y_min", "y_max")] if meta else None + except (KeyError, TypeError, ValueError): + old = None + if old and old[0] <= need["x"][0] and old[1] >= need["x"][1]: + if old[2] <= need["y"][0] and old[3] >= need["y"][1]: + return "skip" + if old: + need = { + "x": [min(old[0], need["x"][0]), max(old[1], need["x"][1])], + "y": [min(old[2], need["y"][0]), max(old[3], need["y"][1])], + } + try: + done = download_vworld_satellite_map(prj, need, processed, layer_name="Satellite") + except Exception as exc: # noqa: BLE001 — 위성은 배경 · 지형 받기를 막지 않음 + logger.warning("D 위성 지도 받기 실패: %s", exc) + return "failed" + return "done" if done else "failed" + + +def ensure_plan_terrain( + project_root: Path, + segments: list[dict[str, Any]], + epsg: int, + on_progress: Progress | None = None, +) -> dict[str, Any]: + """지점 기준 지형 받기 — `segments` = [{year, start: (x, y), end: (x, y)}](작업 좌표계 m). + + 돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 연도별 모델 등록용 dict 목록. + """ + + def report(percent: int, message: str) -> None: + if on_progress is not None: + on_progress(percent, message) + + if not segments: + raise ValueError("연도 구간이 없습니다.") + processed = project_root / STAGE / "processed" + processed.mkdir(parents=True, exist_ok=True) + prj = write_plan_prj(project_root, epsg) + crs = f"EPSG:{int(epsg)}" + + report(5, "수치지형도 도엽 고르는 중") + points = reference_points(segments, epsg) + wanted = neighbors_for_points(points) + store = get_project_map_sheets_dir(project_root) + before = {s for s in wanted if get_sheet_path(store, s) is not None} + report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중") + got = ensure_sheets(store, wanted) + if not got["available"]: + raise ValueError(f"수치지형도 도엽을 하나도 받지 못했습니다: {got['failed']}") + report(45, "도엽 레이어 병합 중") + merged = _merge_sheets(processed, store, list(got["available"])) + report(60, "위성 지도 받는 중") + satellite = _satellite(processed, prj, points, epsg) + + years: dict[int, list[dict[str, Any]]] = {} + for i, seg in enumerate(segments): + year = int(seg["year"]) + report(65 + 30 * i // len(segments), f"{year}년 구간 지형면 만드는 중") + models_dir = models_dir_of(project_root, year) + models_dir.mkdir(parents=True, exist_ok=True) + years[year] = build_sheet_surface_model( + project_root, + processed, + models_dir, + dem_outline(seg["start"], seg["end"]), + crs, + [SHEET_SURFACE_DEFAULT_METHOD], + ) + if not years[year]: + raise ValueError(f"{year}년 구간 지형면을 만들지 못했습니다 — 도엽 등고선 확인") + report(100, "지형 받기 끝") + return { + "sheets": { + "wanted": len(wanted), + "downloaded": sorted(set(got["available"]) - before), + "failed": got["failed"], + "merged": merged, + }, + "satellite": satellite, + "years": years, + } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts new file mode 100644 index 000000000..4cdb60430 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts @@ -0,0 +1,148 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_3D.ts + * D03 [3D] 큰 모달(PLAN 530-3 · 544-3) — 한 연도 DEM + 그 연도 고른 노선 + 측점 막대 + 예상 코리도. + * 예상 코리도 = 측점마다 횡단 판과 같은 예상 단면(`logic/D03_PlanAnalysis_Expect`) → B05 `buildCorridor` + * → `setCorridor`(노면 · 측구 · 절성토면 · 지형 잘림) · 구조물 없음 · 「예상 코리도」 켬끔. + * 연도 고름 = 모달 위 칸 · 뷰어 = B05 `createRouteViewer` 그대로 · 닫으면 버림(WebGL 문맥까지). + * ========================================================================== */ + +import "../B05_Profile/B05_Profile_UI_Style.css"; +import { openModal } from "@ui/ui_template_modal"; +import { createProgressCircle } from "@ui/ui_template_progress"; +import { showToast } from "@ui/ui_template_elements"; +import type { DtmGrid } from "../common_util/common_util_surface_sampler"; +import { createRouteViewer, type RouteViewer } from "../B05_Profile/B05_Profile_UI_Viewer"; +import { sectionsGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; +import { buildCorridor } from "../B05_Profile/logic/B05_Profile_Corridor_Build"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Types"; +import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { expectSections } from "./logic/D03_PlanAnalysis_Expect"; +import { loadExpectStandard } from "./D03_PlanAnalysis_UI_Cross"; + +export interface Plan3dYear { + year: number; + modelId: number; + grid: DtmGrid; + line: Vertex[]; + /** 그 연도 노선 위 측점 누가(연도 시작 = 0) · 등록 누가. */ + stations: Array<{ localM: number; chainage: number }>; +} + +/** 격자 → 뷰어 상자(지형에 붙는 노선 · 막대는 높이 원점과 무관 · x · y 만 메시와 맞으면 됨). */ +function gridBounds(grid: DtmGrid): { + x: [number, number]; + y: [number, number]; + z: [number, number]; +} { + let low = Infinity; + let high = -Infinity; + for (const z of grid.z) { + if (!Number.isFinite(z)) continue; + if (z < low) low = z; + if (z > high) high = z; + } + return { + x: [grid.x[0], grid.x[grid.x.length - 1]], + y: [grid.y[0], grid.y[grid.y.length - 1]], + z: [low, high], + }; +} + +export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: number): void { + if (!years.length) return void showToast("3D 로 볼 연도 노선이 없습니다.", "warning"); + let viewer: RouteViewer | null = createRouteViewer(); + const pick = document.createElement("select"); + pick.className = "d03-plan__3d-year"; + for (const item of years) + pick.append(new Option(`${item.year}년`, String(item.year), false, item.year === firstYear)); + const corridorBox = document.createElement("label"); + corridorBox.className = "d03-plan__3d-corridor"; + const corridorOn = Object.assign(document.createElement("input"), { + type: "checkbox", + checked: true, + }); + corridorBox.append(corridorOn, " 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)"); + corridorOn.addEventListener("change", () => viewer?.setCorridorVisible(corridorOn.checked)); + const top = document.createElement("div"); + top.className = "d03-plan__3d-top"; + top.append(pick, corridorBox); + const standard = loadExpectStandard(projectId); + + const load = async (year: number): Promise => { + const item = years.find((entry) => entry.year === year) ?? years[0]; + if (!viewer) return; + const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); + progress.set(null); + viewer.root.append(progress.root); + const started = performance.now(); + const stat: Record = { year: item.year, done: false }; + (window as unknown as { __d03Plan3d?: unknown }).__d03Plan3d = stat; + try { + await viewer.loadSurface(projectId, item.modelId, "dtm", true, 5, gridBounds(item.grid)); + if (!viewer) return; + viewer.markers.renderRoute(item.line.map(([x, y]) => ({ x, y }))); + const ground = sectionsGround( + item.line, + item.stations.map((s) => s.localM), + item.grid, + ); + viewer.renderStationLines( + ground.stations.map((station, i) => ({ + station_id: String(item.stations[i]?.chainage ?? station.chainage_m), + center_x: station.center_x, + center_y: station.center_y, + center_z: station.center_z, + frame: { left_xy: station.left_xy }, + kind: "regular", + chainage_m: item.stations[i]?.chainage ?? station.chainage_m, + })), + 2.5, + ); + const std = await standard; + if (!viewer) return; + const built = performance.now(); + const sections = std + ? (expectSections(ground.stations, std, item.grid) as unknown as CrossSection[]) + : []; + const build = buildCorridor( + sections, + item.line.map(([x, y]) => ({ x, y })), + ); + if (build) build.sections = sections; // 지형 잘림이 읽음 + viewer.setCorridor(build); + viewer.setCorridorVisible(corridorOn.checked); + Object.assign(stat, { + sections: sections.length, + corridor: !!build, + buildMs: Math.round(performance.now() - built), + centers: sections.map((c) => [c.center_x, c.center_y, c.center_z]), + }); + viewer.setView("iso"); + } catch (error) { + showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); + } finally { + stat.done = true; + stat.totalMs = Math.round(performance.now() - started); + progress.remove(); + } + }; + pick.addEventListener("change", () => void load(Number(pick.value))); + + openModal({ + title: "3D 보기 — 지형 + 노선", + closeLabel: "닫기", + wide: true, + dialogClass: "b05-route__3d-modal", + mount: (body) => { + body.classList.add("b05-route__3d-body"); + body.append(top, viewer!.root); + viewer!.setActive(true); + }, + beforeClose: () => { + viewer?.dispose(); + viewer = null; + return true; + }, + }); + void load(Number(pick.value)); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts new file mode 100644 index 000000000..2f2c41eee --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts @@ -0,0 +1,123 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Cross.ts + * D03 하 패널 — 누른 측점의 지반선 + 예상 횡단(PLAN 530-3 · 544-2). 계산 = 화면 TS 즉시(① 짝 `sectionsGround` ↔ + * `generate_sections` · 격자 = 그 연도 DEM 평활본) · 예상 = `logic/D03_PlanAnalysis_Expect`(표준 횡단 기본값 · + * 계획고 = 중심 지반고 · 표준값 = `sections/context` 세션 캐시) · 그림 = B05 `buildCrossSvg`. + * ========================================================================== */ + +import "../B05_Profile/B05_Profile_UI_Style_RouteEdit.css"; +import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { stationLabel } from "../common_util/common_util_station"; +import type { DtmGrid } from "../common_util/common_util_surface_sampler"; +import { buildCrossSvg } from "../B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw"; +import { + sectionsGround, + type StationGround, +} from "../B05_Profile/logic/B05_Profile_Sections_Ground"; +import { fetchSectionContext } from "../B06_Section/B06_Section_Api_Context"; +import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { expectCross, type ExpectStandard } from "./logic/D03_PlanAnalysis_Expect"; +import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; + +export interface PlanCross { + root: HTMLElement; + /** `chainage` = 등록 누가 · `localM` = 그 연도 노선 위 누가 · 돌려줌 = 그린 측점 지반(못 읽으면 null). */ + show: ( + chainage: number, + year: number, + line: Vertex[], + localM: number, + grid: DtmGrid, + ) => StationGround | null; + clear: (text: string) => void; +} + +/** 표준 횡단 기본값(config · 서버 한 번 · 세션 캐시) — 못 받으면 null(지반선만). */ +export function loadExpectStandard(projectId: string): Promise { + return fetchSectionContext(projectId).then( + (context) => ({ + standard: context.standard_cross_section as ExpectStandard["standard"], + rockBoundaryM: context.rock_boundary_default_offset_m, + }), + () => null, + ); +} + +export function createPlanCross(): PlanCross { + const root = document.createElement("section"); + root.className = "d03-plan__cross"; + const handle = createWorkflowPanelHandle("bottom", "down", "횡단"); + const head = document.createElement("div"); + head.className = "d03-plan__cross-head"; + const body = document.createElement("div"); + body.className = "d03-plan__cross-body"; + root.append(handle.root, head, body); + let open = true; + handle.setOpen(open); + handle.root.addEventListener("click", () => { + open = !open; + root.classList.toggle("is-collapsed", !open); + handle.setOpen(open); + }); + + const clear = (text: string): void => { + head.textContent = ""; + body.replaceChildren(Object.assign(document.createElement("p"), { textContent: text })); + delete root.dataset.chainage; + }; + clear("평면에서 측점을 누르면 그 자리 지반선 · 예상 횡단이 보입니다."); + let standard: ExpectStandard | null = null; + let asked: Promise | null = null; + + return { + root, + clear, + show(chainage, year, line, localM, grid) { + const station = sectionsGround(line, [localM], grid).stations[0]; + if (!station) { + clear("이 측점의 지반을 읽지 못했습니다."); + return null; + } + const name = stationLabel(chainage, PLAN_STATION_INTERVAL_M, 1); + const z = station.center_z === null ? "—" : `${station.center_z.toFixed(2)} m`; + root.dataset.chainage = String(chainage); + const draw = (): void => { + const expect = standard ? expectCross(station, standard, grid) : null; + const area = expect + ? ` · 예상(표준 횡단 기본값 · 계획고 = 지반고) 절토 ${expect.design.cut_area_m2.toFixed(2)}㎡ · 성토 ${expect.design.fill_area_m2.toFixed(2)}㎡` + : ""; + head.textContent = `${year}년 · ${name} · 중심 지반고 ${z}${area}`; + const rect = body.getBoundingClientRect(); + const plot = buildCrossSvg( + { + status: "success", + chainage_m: chainage, + label: name, + uphill_side: station.uphill_side, + plan_radius_m: null, + curve_widening_m: null, + cross_half_width_m: 20, + samples: expect?.samples ?? station.samples, + design: expect ? { ...expect.design } : null, + }, + Math.max(rect.width, 320), + Math.max(rect.height, 160), + !expect, + ); + body.replaceChildren(plot.svg); + }; + draw(); + if (!standard) { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + asked ??= loadExpectStandard(projectId); + void asked.then((value) => { + standard = value; + // 그사이 다른 측점을 눌렀으면 그 측점이 다시 그림 + if (value && root.dataset.chainage === String(chainage)) draw(); + }); + } + return station; + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts new file mode 100644 index 000000000..4200793a9 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -0,0 +1,186 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Edit.ts + * D03 평면 꺾임점 고침 겹(요구 363-1 · PLAN 543-2) — 고른 갈래 노선의 마디(네모) · 곡선 손잡이(동그라미) · + * R 글을 그리고, 끌기 · 두 번 누름(넣기) · 오른쪽 누름(지우기)을 순수 셈(`logic/D03_PlanAnalysis_Edit`) + * 으로 넘김. 처음 · 끝 마디(연도 경계)는 지점 끌기 몫이라 안 그림 · 안 집음. + * + * 값은 페이지가 쥠(캐시 `d03-plan` · 되돌리기) — 여기는 고친 값을 `change` 로 올리기만. + * 집기는 B05 편집기 순수 부품 그대로(`nodeAtScreen` · `handleAtScreen` · `segmentAtScreen`). + * ========================================================================== */ + +import { + handleAtScreen, + nodeAtScreen, + segmentAtScreen, +} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; +import type { EditedPolyline } from "../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; +import type { PlanMapOverlay, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +import { + insertNode, + moveHandle, + moveNode, + removeNode, + type EditStep, + type PlanEdit, +} from "./logic/D03_PlanAnalysis_Edit"; +import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; + +/** 마디 · 손잡이 집는 반경 · 선 위 두 번 누름 거리(px). */ +const NODE_HIT_PX = 10; +const HANDLE_HIT_PX = 8; +const SEGMENT_HIT_PX = 10; + +/** 연도 하나 — 지금 고침(없으면 엔진 노선을 편 것) · 그 선 · 법정 최소 R. */ +export interface EditTarget { + year: number; + edit: PlanEdit; + line: EditedPolyline; + minRadiusM: number; +} + +export interface PlanEditEvents { + /** 고치기 직전 한 번(되돌리기 쌓기). */ + begin: () => void; + /** 고친 값 — `final` = 끌기 끝 · 넣기 · 지우기(캐시 · 횡단 다시). */ + change: (year: number, edit: PlanEdit, final: boolean) => void; + /** 막힌 까닭(하한 · 끝 잠금). */ + blocked: (message: string) => void; +} + +export interface PlanEditLayer extends PlanMapOverlay { + setTargets: (targets: EditTarget[]) => void; + targets: () => EditTarget[]; +} + +type Grab = { year: number; index: number; which: "node" | "start" | "end" }; + +export function createPlanEditLayer(events: PlanEditEvents): PlanEditLayer { + let targets: EditTarget[] = []; + let grab: Grab | null = null; + let moved = false; + let stop = ""; + + const interior = (edit: PlanEdit): Vertex[] => edit.nodes.slice(1, -1); + + function apply(target: EditTarget, step: EditStep, final: boolean): void { + if ("blocked" in step) { + stop = step.blocked; + if (final) events.blocked(stop); + return; + } + target.edit = step.edit; + target.line = step.line; + events.change(target.year, step.edit, final); + } + + return { + setTargets(next) { + targets = next; + }, + targets: () => targets, + + draw(context, map) { + context.save(); + context.font = "11px system-ui, sans-serif"; + context.textAlign = "left"; + context.textBaseline = "bottom"; + for (const target of targets) { + for (const curve of target.line.curves) { + for (const end of [curve.start, curve.end]) { + const [x, y] = map.toScreen(end); + context.beginPath(); + context.arc(x, y, 4, 0, Math.PI * 2); + context.fillStyle = "#f59e0b"; + context.fill(); + context.lineWidth = 1.5; + context.strokeStyle = "#ffffff"; + context.stroke(); + } + const [ax, ay] = map.toScreen(curve.apex); + context.lineWidth = 3; + context.strokeStyle = "rgba(0, 0, 0, 0.7)"; + context.strokeText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5); + context.fillStyle = curve.violations.length ? "#fca5a5" : "#ffffff"; + context.fillText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5); + } + interior(target.edit).forEach((node, i) => { + const [x, y] = map.toScreen(node); + context.fillStyle = "#ffffff"; + context.fillRect(x - 4.5, y - 4.5, 9, 9); + context.lineWidth = 2; + const bad = target.line.nodes[i + 1]?.violations.length; + context.strokeStyle = bad ? "#dc2626" : "#2563eb"; + context.strokeRect(x - 4.5, y - 4.5, 9, 9); + }); + } + context.restore(); + }, + + grab(px, py, map) { + grab = null; + moved = false; + stop = ""; + for (const target of targets) { + const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX); + if (node >= 0) { + grab = { year: target.year, index: node + 1, which: "node" }; + return true; + } + } + for (const target of targets) { + const hit = handleAtScreen(target.line.curves, map.toScreen, px, py, HANDLE_HIT_PX); + if (hit) { + grab = { year: target.year, index: hit.node, which: hit.end }; + return true; + } + } + return false; + }, + + move(point) { + const target = grab && targets.find((entry) => entry.year === grab!.year); + if (!grab || !target) return; + if (!moved) events.begin(); + moved = true; + const step = + grab.which === "node" + ? moveNode(target.edit, grab.index, point, target.minRadiusM) + : moveHandle(target.edit, grab.index, grab.which, point, target.minRadiusM); + if (!("blocked" in step)) stop = ""; + apply(target, step, false); + }, + + release() { + const target = grab && targets.find((entry) => entry.year === grab!.year); + grab = null; + if (!moved || !target) return; + moved = false; + events.change(target.year, target.edit, true); + if (stop) events.blocked(stop); + }, + + insert(px: number, py: number, map: ScreenMap) { + for (const target of targets) { + const segment = segmentAtScreen(target.edit.nodes, map.toScreen, px, py, SEGMENT_HIT_PX); + if (segment < 0) continue; + events.begin(); + apply( + target, + insertNode(target.edit, segment, map.toMetric(px, py), target.minRadiusM), + true, + ); + return; + } + }, + + remove(px: number, py: number, map: ScreenMap) { + for (const target of targets) { + const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX); + if (node < 0) continue; + events.begin(); + apply(target, removeNode(target.edit, node + 1, target.minRadiusM), true); + return; + } + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts new file mode 100644 index 000000000..29975cf1b --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -0,0 +1,413 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Map.ts + * D03 메인 평면(PLAN 530-3) — 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점 눈금 · 이름 + + * 지점(연도 경계) + 지점 끌기 겹. + * + * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `metricToScreen` · `screenToMetric` · 맞춤 + * `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` · + * 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선 + * `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시). + * 꺾임점 고침 겹(543 · `overlay`) = 지점 다음 차례로 집음 · 두 번 누름 = 넣기 · 오른쪽 누름 = 지우기. + * ========================================================================== */ + +import { themeColor } from "@ui/ui_template_palette"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + drawStationTicks, + type StationLabelBox, +} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import { + computeMapRect, + computeRouteView, + createMetricProjector, + drawPreparedLabels, + drawPreparedLayer, + normalizedToScreen, + pickLevelStep, + routeLineColor, + type PreparedLayer, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { + bindRouteEditNavigation, + stationAtScreen, +} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; +import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View"; +import { leadInLine, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; + +/** 측점 간격(m) — B 규칙 측점과 같음. */ +export const PLAN_STATION_INTERVAL_M = 20; +/** 지점 집는 반경(px). */ +const NODE_HIT_PX = 12; +/** 누름으로 볼 손 떨림(px) — 넘으면 지도 옮기기. */ +const CLICK_SLOP_PX = 4; +/** 위성이 없을 때 지점 둘레로 둘 가짜 도엽 여유(m). */ +const FAKE_MARGIN_M = 600; + +export interface PlanMapScene { + meta: VWorldMeta | null; + image: HTMLImageElement | null; + contours: PreparedLayer | null; + nodes: Vertex[]; + /** 마디마다 받은 지형 안인가 — 밖이면 빨간 고리. */ + reach: boolean[]; + /** 고르지 않은 갈래(옅게). */ + others: Vertex[][]; + /** 고른 갈래를 이은 중심선. */ + line: Vertex[]; + startM: number; + stations: number[]; + selected: number | null; + editable: boolean; +} + +/** 화면 px ↔ 작업 좌표계 m. */ +export interface ScreenMap { + toScreen: (point: Vertex) => [number, number]; + toMetric: (px: number, py: number) => Vertex; +} + +/** 평면 위 고침 겹 — 그리기 · 집기(참 = 잡음) · 끌기 · 놓기 · 넣기 · 지우기. */ +export interface PlanMapOverlay { + draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void; + grab: (px: number, py: number, map: ScreenMap) => boolean; + move: (point: Vertex) => void; + release: () => void; + insert: (px: number, py: number, map: ScreenMap) => void; + remove: (px: number, py: number, map: ScreenMap) => void; +} + +export interface PlanMapEvents { + onDrag: (index: number, point: Vertex) => void; + onDragEnd: (index: number) => void; + onStation: (chainage: number) => void; + overlay?: PlanMapOverlay; +} + +export interface PlanMap { + root: HTMLElement; + update: (patch: Partial) => void; + /** 지점 · 노선이 들어오게 다시 맞춤. */ + fit: () => void; + /** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */ + stationScreen: (chainage: number) => [number, number] | null; + /** 검증 창구 — 작업 좌표계 점 → 화면 px(그림 좌표 · 그리기 전이면 null). */ + screenOf: (point: Vertex) => [number, number] | null; + dispose: () => void; +} + +function fakeMeta(points: Vertex[]): VWorldMeta | null { + if (!points.length) return null; + const xs = points.map(([x]) => x); + const ys = points.map(([, y]) => y); + const x_min = Math.min(...xs) - FAKE_MARGIN_M; + const y_min = Math.min(...ys) - FAKE_MARGIN_M; + const x_max = Math.max(...xs) + FAKE_MARGIN_M; + const y_max = Math.max(...ys) + FAKE_MARGIN_M; + return { + x_min, + x_max, + y_min, + y_max, + width_meters: x_max - x_min, + height_meters: y_max - y_min, + center_x: (x_min + x_max) / 2, + center_y: (y_min + y_max) / 2, + lon_min: 0, + lon_max: 1, + lat_min: 0, + lat_max: 1, + }; +} + +export function createPlanMap(events: PlanMapEvents): PlanMap { + const root = document.createElement("div"); + root.className = "d03-plan__map"; + const canvas = document.createElement("canvas"); + root.append(canvas); + const context = canvas.getContext("2d")!; + let scene: PlanMapScene = { + meta: null, + image: null, + contours: null, + nodes: [], + reach: [], + others: [], + line: [], + startM: 0, + stations: [], + selected: null, + editable: false, + }; + let meta: VWorldMeta | null = null; + let fitted = false; + let labels: StationLabelBox[] = []; + let view: ViewState = { + width: 0, + height: 0, + scale: 1, + offsetX: 0, + offsetY: 0, + mapRect: computeMapRect(null, 0, 0), + }; + + const toScreen = (point: Vertex): [number, number] => + createMetricProjector(meta!, view).toScreen(point[0], point[1]); + const screenMap: ScreenMap = { + toScreen, + toMetric: (px, py) => screenToMetric(meta!, view, px, py), + }; + + function refit(): void { + const all = [...scene.nodes, ...scene.line]; + meta = scene.meta ?? fakeMeta(all); + if (!meta || !view.width || !all.length) return; + const xs = all.map(([x]) => x); + const ys = all.map(([, y]) => y); + const box = { + x_min: Math.min(...xs), + x_max: Math.max(...xs), + y_min: Math.min(...ys), + y_max: Math.max(...ys), + }; + view = { + ...view, + mapRect: computeMapRect(meta, view.width, view.height), + ...computeRouteView(meta, box, view.width, view.height, 150), + }; + fitted = true; + } + + function polyline(points: Vertex[]): void { + context.beginPath(); + points.forEach((point, i) => { + const [sx, sy] = toScreen(point); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.stroke(); + } + + function draw(): void { + context.clearRect(0, 0, view.width, view.height); + if (!meta) return; + const projector = createMetricProjector(meta, view); + if (scene.image?.complete && scene.image.naturalWidth && scene.meta) { + const [x0, y0] = normalizedToScreen(view, 0, 0); + const [x1, y1] = normalizedToScreen(view, 1, 1); + context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0); + } + if (scene.contours) { + context.save(); + context.strokeStyle = "rgba(255, 214, 140, 0.7)"; + context.lineWidth = 0.8; + drawPreparedLayer( + context, + scene.contours, + view, + "dot", + pickLevelStep(scene.contours, view, 5), + ); + context.font = "10px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + drawPreparedLabels(context, scene.contours, view, "#fde68a", 25, 0, "rgba(0, 0, 0, 0.6)"); + context.restore(); + } + context.save(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = "rgba(255, 255, 255, 0.55)"; + context.lineWidth = 1.5; + context.setLineDash([6, 4]); + for (const other of scene.others) if (other.length > 1) polyline(other); + context.setLineDash([]); + if (scene.line.length > 1) { + context.strokeStyle = routeLineColor(); + context.lineWidth = 3; + polyline(scene.line); + } + context.restore(); + labels = []; + if (scene.line.length > 1) { + drawStationTicks( + context, + leadInLine(scene.line, scene.startM).map(([x, y]) => ({ x, y })), + { + intervalM: PLAN_STATION_INTERVAL_M, + pxPerMeter: projector.pxPerMeter, + toScreen: projector.toScreen, + chainages: scene.stations, + onLabel: (box) => labels.push(box), + }, + ); + } + if (scene.selected !== null) { + const at = stationScreen(scene.selected); + if (at) { + context.beginPath(); + context.arc(at[0], at[1], 7, 0, Math.PI * 2); + context.lineWidth = 2.5; + context.strokeStyle = themeColor("--color-primary", "#6d28d9"); + context.stroke(); + } + } + if (scene.editable) events.overlay?.draw(context, screenMap); + scene.nodes.forEach((node, i) => { + const [sx, sy] = toScreen(node); + const last = i === scene.nodes.length - 1; + context.beginPath(); + context.arc(sx, sy, 7, 0, Math.PI * 2); + context.fillStyle = i === 0 ? "#16a34a" : last ? "#dc2626" : "#2563eb"; + context.fill(); + context.lineWidth = 2; + context.strokeStyle = scene.reach[i] === false ? "#ef4444" : "#ffffff"; + context.stroke(); + if (scene.reach[i] === false) { + context.beginPath(); + context.arc(sx, sy, 12, 0, Math.PI * 2); + context.setLineDash([3, 3]); + context.stroke(); + context.setLineDash([]); + } + }); + } + + /** 측점 누가 → 화면 px — 앞붙임 선 위 보간(눈금과 같은 자리). */ + function stationScreen(chainage: number): [number, number] | null { + if (!meta || scene.line.length < 2) return null; + const line = leadInLine(scene.line, scene.startM); + let travelled = 0; + for (let i = 1; i < line.length; i += 1) { + const [a, b] = [line[i - 1], line[i]]; + const span = Math.hypot(b[0] - a[0], b[1] - a[1]); + if (travelled + span >= chainage - 1e-6) { + const t = span ? (chainage - travelled) / span : 0; + return toScreen([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + travelled += span; + } + return toScreen(line[line.length - 1]); + } + + function resize(): void { + const { width, height } = fitCanvas(canvas, context); + if (!width || !height) return; + view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; + if (!fitted) refit(); + draw(); + } + + const at = (event: PointerEvent | MouseEvent): [number, number] => { + const rect = canvas.getBoundingClientRect(); + return [event.clientX - rect.left, event.clientY - rect.top]; + }; + const nodeAt = (px: number, py: number): number => { + let best = -1; + let bestDistance = NODE_HIT_PX; + scene.nodes.forEach((node, i) => { + const [sx, sy] = toScreen(node); + const distance = Math.hypot(sx - px, sy - py); + if (distance < bestDistance) [best, bestDistance] = [i, distance]; + }); + return best; + }; + + // 지점 끌기 — 지도 옮기기보다 먼저 듣고 지점을 집었으면 옮기기에 안 넘김 + // 집은 자리와 지점 가운데 차(px)를 지킴 · 손 떨림(CLICK_SLOP_PX) 안이면 끌기 아님(누름) + let dragging: { index: number; id: number; dx: number; dy: number; moved: boolean } | null = null; + let press: [number, number] | null = null; + canvas.addEventListener("pointerdown", (event) => { + press = [event.clientX, event.clientY]; + if (!meta || !scene.editable || event.button !== 0) return; + const [px, py] = at(event); + // 지점 먼저 · 다음 고침 겹(index -1) + const index = nodeAt(px, py); + if (index < 0 && !events.overlay?.grab(px, py, screenMap)) return; + event.stopImmediatePropagation(); + const [sx, sy] = index < 0 ? [px, py] : toScreen(scene.nodes[index]); + dragging = { index, id: event.pointerId, dx: sx - px, dy: sy - py, moved: false }; + canvas.setPointerCapture(event.pointerId); + canvas.style.cursor = "grabbing"; + }); + canvas.addEventListener("pointermove", (event) => { + if (!dragging || event.pointerId !== dragging.id || !meta) { + if (meta && scene.editable && !dragging) + canvas.style.cursor = nodeAt(...at(event)) >= 0 ? "grab" : ""; + return; + } + if (!press) return; + const far = Math.hypot(event.clientX - press[0], event.clientY - press[1]) > CLICK_SLOP_PX; + if (!dragging.moved && !far) return; + dragging.moved = true; + const [px, py] = at(event); + const point = screenToMetric(meta, view, px + dragging.dx, py + dragging.dy); + if (dragging.index < 0) events.overlay?.move(point); + else events.onDrag(dragging.index, point); + }); + const endDrag = (event: PointerEvent): void => { + if (!dragging || event.pointerId !== dragging.id) return; + const { index, moved } = dragging; + dragging = null; + canvas.style.cursor = ""; + if (index < 0) events.overlay?.release(); + else if (moved) events.onDragEnd(index); + }; + canvas.addEventListener("pointerup", endDrag); + canvas.addEventListener("pointercancel", endDrag); + bindRouteEditNavigation({ + canvas, + getView: () => view, + setView: (next) => (view = next), + getMeta: () => meta, + leftPan: true, + draw, + }); + // 측점 누름 — 손을 거의 안 움직인 누름만(옮기기 끝은 안 셈) + canvas.addEventListener("click", (event) => { + if (!press || Math.hypot(event.clientX - press[0], event.clientY - press[1]) > CLICK_SLOP_PX) + return; + if (!meta || scene.line.length < 2) return; + const [px, py] = at(event); + const hit = stationAtScreen( + leadInLine(scene.line, scene.startM), + toScreen, + PLAN_STATION_INTERVAL_M, + px, + py, + 14, + labels, + scene.stations, + ); + if (hit) events.onStation(hit.chainage); + }); + + canvas.addEventListener("dblclick", (event) => { + if (meta && scene.editable) events.overlay?.insert(...at(event), screenMap); + }); + canvas.addEventListener("contextmenu", (event) => { + if (!meta || !scene.editable) return; + event.preventDefault(); + events.overlay?.remove(...at(event), screenMap); + }); + + const observer = new ResizeObserver(() => resize()); + observer.observe(root); + + return { + root, + update(patch) { + const metaChanged = "meta" in patch && patch.meta !== scene.meta; + scene = { ...scene, ...patch }; + if (metaChanged || !fitted) refit(); + draw(); + }, + fit() { + refit(); + draw(); + }, + stationScreen, + screenOf: (point) => (meta ? toScreen(point) : null), + dispose: () => observer.disconnect(), + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts new file mode 100644 index 000000000..dbad0a984 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -0,0 +1,693 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Page.ts + * D 예정노선 2단계 분석 화면(요구서 345 3-3 · PLAN 530-3) — B05~B09 틀(`mountGroupStep`). + * + * 메인 = 평면(위성 + 도엽 등고선 + 지점 + 연도 구간 노선 · 측점) · 하 = 누른 측점 지반선 횡단 · + * 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D]. + * 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」. + * + * 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 · 꺾임점 고침(543) = 캐시(`d03-plan`) · [저장] = + * PUT plan/analysis(끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 · 고친 노선은 + * 마디로 서버가 다시 셈). 꺾임점 고침 = 브라우저 즉시 다시 그음 → 길이 · 측점 · 누른 측점 횡단 · + * 되돌리기(Ctrl+Z). 화면 전체 잠금 = [저장]만. + * 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음. + * ========================================================================== */ + +import "./D03_PlanAnalysis_UI_Style.css"; +import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { + createButton, + hideLoadingOverlay, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { fetchCachedSheetLayer, DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { + fetchVWorldMeta, + getVWorldMapUrl, + type VWorldMeta, +} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + createNormalizer, + type GeoJsonCollection, + type PreparedLayer, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; +import { stationLabel } from "../common_util/common_util_station"; +import type { StationGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; +import { + fetchPlanAnalysis, + fetchPlanPoints, + fetchPlanProgress, + fetchYearSurfaces, + savePlanAnalysis, + startPlanJob, + type PlanAnalysisView, + type SavedEdit, + type PlanJob, + type YearSurface, +} from "./D03_PlanAnalysis_Api"; +import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; +import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross"; +import { createPlanEditLayer, type EditTarget } from "./D03_PlanAnalysis_UI_Edit"; +import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; +import { + chainRows, + joinedLine, + nodesInReach, + nodesOf, + PLAN_MODES, + pointsOf, + rowAt, + sameNodes, + stationChainages, + withEdits, + type ChainRow, + type RouteEdit, + type Vertex, + type YearAnalysis, +} from "./logic/D03_PlanAnalysis_Plan"; +import { + buildEdit, + editFromRoute, + moveNode, + sameEdit, + type PlanEdit, +} from "./logic/D03_PlanAnalysis_Edit"; + +const MODE_LABEL: Record = { + gradeline: "영선", + earthwork: "토공 최소", + gentle: "기울기 최소", + weighted: "비중 계산", +}; +const POLL_MS = 1000; +/** 등고선을 받아 올 지점 둘레(m). */ +const CONTOUR_PAD_M = 600; + +/** 되돌리기 쌓는 수. */ +const UNDO_MAX = 50; + +interface Draft { + nodes: Vertex[] | null; + choices: Record; + /** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */ + edits?: Record; +} + +type Snapshot = { edits: Record; choices: Record }; + +const sleep = (ms: number): Promise => new Promise((done) => setTimeout(done, ms)); +const errorText = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +export async function renderD03PlanAnalysis(root: HTMLElement): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const editLayer = createPlanEditLayer({ + begin: remember, + change: onEdit, + blocked: (message) => showToast(message, "info"), + }); + const map = createPlanMap({ onDrag, onDragEnd, onStation, overlay: editLayer }); + const cross = createPlanCross(); + const main = document.createElement("div"); + main.className = "d03-plan"; + main.append(map.root, cross.root); + + const terrainText = document.createElement("p"); + terrainText.className = "d03-plan__hint"; + const bar = document.createElement("progress"); + bar.className = "d03-plan__bar"; + bar.max = 100; + bar.hidden = true; + const more = createButton({ + label: "범위 밖 — 더 받기", + variant: "filled", + onClick: () => void download(), + }); + more.hidden = true; + const yearList = document.createElement("div"); + yearList.className = "d03-plan__years"; + const analyzeButton = createButton({ + label: "분석", + variant: "ghost", + onClick: () => void analyze(), + }); + const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() }); + const viewButton = createButton({ label: "3D", variant: "ghost", onClick: () => open3d() }); + const undoButton = createButton({ label: "되돌리기", variant: "ghost", onClick: () => undo() }); + undoButton.title = "꺾임점 고침 · 갈래 고름 한 걸음 되돌림(Ctrl+Z)"; + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + actions.append(analyzeButton, saveButton, viewButton, undoButton); + const terrainCard = createPanelCard("지형", false); + terrainCard.append(terrainText, bar, more); + const routeCard = createPanelCard("연도별 노선", false); + routeCard.append(yearList); + const panel = document.createElement("div"); + panel.className = "ui-sidebar-panel d03-plan__panel"; + panel.append(terrainCard, routeCard, actions); + attachCollapsible(panel); + mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mainContent: main, leftPanel: panel }); + root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); + + let years: number[] = []; + let savedNodes: Vertex[] = []; + let nodes: Vertex[] = []; + let choices: Record = {}; + let startM = 0; + let targetM: number | null = null; // 등록 목표 끝 누가 + let analysis: PlanAnalysisView | null = null; + let surfaces = new Map(); + let rows: ChainRow[] = []; + let results: YearAnalysis[] = []; + let selected: number | null = null; + let editable = false; + let busy: "terrain" | "analyze" | null = null; + let lastCross: StationGround | null = null; + // 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기 + let edits: Record = {}; + let serverEdits: Record = {}; + let draftEdits: Record | undefined; + let history: Snapshot[] = []; + let lastSaveMs: number | null = null; + + const moved = (): boolean => !sameNodes(nodes, savedNodes); + const sentPoints = () => (moved() ? pointsOf(years, nodes) : null); + const reach = (): boolean[] => + nodesInReach( + nodes, + years.map((year) => { + const grid = surfaces.get(year)?.grid; + return grid + ? { + x0: grid.x[0], + x1: grid.x[grid.x.length - 1], + y0: grid.y[0], + y1: grid.y[grid.y.length - 1], + } + : null; + }), + ); + const keepDraft = (): void => + writeState( + "d03-plan", + { nodes: moved() ? nodes : null, choices, edits } satisfies Draft, + projectId, + ); + + /** 고치기 직전 — 되돌리기에 지금 값을 쌓음. */ + function remember(): void { + history = [...history.slice(1 - UNDO_MAX), structuredClone({ edits, choices })]; + } + + function undo(): void { + const last = history.pop(); + if (!last) return; + ({ edits, choices } = last); + keepDraft(); + refresh(); + repick(); + } + + /** 누른 측점 횡단을 지금 노선으로 다시. */ + function repick(): void { + if (selected !== null && rowAt(rows, selected)) onStation(selected); + } + + function onEdit(year: number, edit: PlanEdit, final: boolean): void { + edits = { ...edits, [year]: edit }; + refresh(); + if (!final) return; + keepDraft(); + repick(); + } + + /** 연도의 지금 고침 — 고른 갈래 · 분석 지문이 같을 때만. */ + const editOf = (result: YearAnalysis, mode: string | null): PlanEdit | null => { + const own = edits[result.year]; + return own && own.base_mode === mode && own.base_fingerprint === result.fingerprint + ? own + : null; + }; + + /** 연도 목록에서 뺌. */ + const without = (from: Record, drop: number[]): Record => + Object.fromEntries(Object.entries(from).filter(([year]) => !drop.includes(Number(year)))); + + /** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */ + function refresh(): void { + const raw: YearAnalysis[] = years.map( + (year, i) => + analysis?.years.find((entry) => entry.year === year)?.analysis ?? { + year, + start: nodes[i], + end: nodes[i + 1], + routes: {}, + }, + ); + // 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름 + const saved = Object.fromEntries((analysis?.chain ?? []).map((row) => [row.year, row.mode])); + const picks = { ...saved, ...choices }; + // 꺾임점 고침 — 고른 갈래 노선의 마디(고침 · 없으면 엔진 노선) → 선 → 그 갈래 자리에 갈아 끼움 + const targets: EditTarget[] = []; + const routed: Record = {}; + chainRows(raw, picks, startM).forEach((row, i) => { + const result = raw[i]; + const route = row.mode ? result.routes[row.mode] : undefined; + if (!row.mode || !route?.nodes || !result.fingerprint) return; + const minRadiusM = result.criteria?.min_radius_m ?? 0; + const own = editOf(result, row.mode); + const built = buildEdit( + own ?? editFromRoute(route, row.mode, result.fingerprint), + minRadiusM, + ); + targets.push({ year: result.year, ...built, minRadiusM }); + if (own) + routed[result.year] = { + mode: row.mode, + planned: built.line.vertices, + metrics: sameEdit(own, serverEdits[result.year]) + ? serverEdits[result.year].metrics + : null, + }; + }); + editLayer.setTargets(editable ? targets : []); + results = withEdits(raw, routed); + rows = chainRows(results, picks, startM); + const line = joinedLine(results, rows); + const others: Vertex[][] = results.flatMap((result, i) => { + const routes = PLAN_MODES.filter((mode) => mode !== rows[i].mode) + .map((mode) => result.routes[mode]?.planned) + .filter((planned): planned is Vertex[] => !!planned && planned.length > 1); + return rows[i].mode ? routes : [[nodes[i], nodes[i + 1]]]; + }); + const inReach = reach(); + map.update({ + nodes, + reach: inReach, + others, + line, + startM, + stations: stationChainages(rows, PLAN_STATION_INTERVAL_M), + selected, + editable, + }); + renderYears(); + const outside = inReach.some((ok) => !ok); + const missing = rows.some((row) => !row.mode); + more.hidden = !editable || !outside || busy !== null; + if (busy === null) + terrainText.textContent = outside + ? "빨간 고리 지점이 받은 지형 밖입니다 — 더 받아야 분석합니다." + : `받은 지형 안 · 연도 지형면 ${surfaces.size}/${years.length}`; + analyzeButton.hidden = saveButton.hidden = !editable; + analyzeButton.disabled = busy !== null || outside; + saveButton.disabled = busy !== null || missing; + saveButton.title = missing + ? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요." + : ""; + viewButton.disabled = !rows.some((row) => row.mode && surfaces.has(row.year)); + undoButton.hidden = !editable; + undoButton.disabled = !history.length; + } + + function renderYears(): void { + yearList.replaceChildren( + ...results.map((result, i) => { + const row = rows[i]; + const box = document.createElement("div"); + box.className = "d03-plan__year"; + box.dataset.year = String(result.year); + const head = document.createElement("div"); + head.className = "d03-plan__year-head"; + head.textContent = row.mode + ? `${result.year}년 · ${stationLabel(row.start_chainage_m, PLAN_STATION_INTERVAL_M, 1)} ~ ` + + `${stationLabel(row.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} (${row.length_m.toFixed(1)} m)` + : `${result.year}년 · 분석 전 — [분석]을 누르세요`; + box.append(head); + if (row.mode && editOf(result, row.mode)) box.append(editLine(result.year)); + for (const mode of PLAN_MODES) { + const route = result.routes[mode]; + if (!route) continue; + const card = document.createElement("button"); + card.type = "button"; + card.className = "d03-plan__mode" + (row.mode === mode ? " is-picked" : ""); + card.dataset.mode = mode; + const metrics = route.metrics; + card.disabled = !!route.error || !metrics; + card.title = route.error ?? ""; + const name = document.createElement("strong"); + name.textContent = MODE_LABEL[mode]; + const values = document.createElement("span"); + values.textContent = !metrics + ? "노선 없음" + : metrics.max_grade_pct === undefined + ? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈` + : `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`; + card.append(name, values); + card.addEventListener("click", () => { + if (row.mode === mode) return; + remember(); + choices = { ...choices, [result.year]: mode }; + keepDraft(); + refresh(); + }); + box.append(card); + } + return box; + }), + ...targetLine(), + ); + } + + /** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */ + function editLine(year: number): HTMLElement { + const line = document.createElement("div"); + line.className = "d03-plan__year-head"; + line.textContent = sameEdit(edits[year], serverEdits[year]) + ? "꺾임점 고침(저장됨) " + : "꺾임점 고침 — [저장] 전 "; + const reset = createButton({ + label: "원래 노선", + variant: "ghost", + size: "sm", + onClick: () => { + remember(); + edits = without(edits, [year]); + keepDraft(); + refresh(); + repick(); + }, + }); + reset.hidden = !editable; + line.append(reset); + return line; + } + + /** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */ + function targetLine(): HTMLElement[] { + const last = rows[rows.length - 1]; + if (targetM === null || !last || rows.some((row) => !row.mode)) return []; + const diff = last.end_chainage_m - targetM; + const line = document.createElement("div"); + line.className = "d03-plan__year-head d03-plan__target"; + line.textContent = + `끝 ${stationLabel(last.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} · ` + + `목표 ${stationLabel(targetM, PLAN_STATION_INTERVAL_M, 1)} · ` + + `차이 ${diff >= 0 ? "+" : ""}${diff.toFixed(1)} m`; + return [line]; + } + + function onDrag(index: number, point: Vertex): void { + nodes = nodes.map((node, i) => (i === index ? point : node)); + map.update({ nodes, reach: reach() }); + } + + function onDragEnd(index: number): void { + // 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] · 그 연도 꺾임점 고침도 버림 + const lost = [years[index - 1], years[index]].filter( + (year) => year !== undefined && edits[year], + ); + if (lost.length) { + edits = without(edits, lost); + history = []; + showToast(`지점을 옮겨 ${lost.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info"); + } + if (analysis) + analysis = { + ...analysis, + years: analysis.years.map((entry, i) => + i === index - 1 || i === index ? { ...entry, analysis: null } : entry, + ), + }; + selected = null; + lastCross = null; + cross.clear("지점을 옮겼습니다 — [분석] 뒤 측점을 누르세요."); + keepDraft(); + refresh(); + } + + function onStation(chainage: number): void { + const row = rowAt(rows, chainage); + const surface = row ? surfaces.get(row.year) : undefined; + if (!row?.mode || !surface) return; + const result = results.find((entry) => entry.year === row.year)!; + selected = chainage; + map.update({ selected }); + lastCross = cross.show( + chainage, + row.year, + result.routes[row.mode].planned ?? [], + Math.max(0, chainage - row.start_chainage_m), + surface.grid, + ); + } + + async function loadBackground(): Promise { + let meta: VWorldMeta | null = null; + try { + meta = await fetchVWorldMeta(projectId, "satellite"); + } catch { + meta = null; // 위성 못 받음 — 가짜 도엽 위에 그림 + } + let image: HTMLImageElement | null = null; + let contours: PreparedLayer | null = null; + if (meta) { + image = new Image(); + image.onload = () => map.update({}); + // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 + image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`; + const xs = nodes.map(([x]) => x); + const ys = nodes.map(([, y]) => y); + const bbox = [ + Math.min(...xs) - CONTOUR_PAD_M, + Math.min(...ys) - CONTOUR_PAD_M, + Math.max(...xs) + CONTOUR_PAD_M, + Math.max(...ys) + CONTOUR_PAD_M, + ].map((v) => v.toFixed(0)); + try { + const collection = await fetchCachedSheetLayer( + projectId, + DRAINAGE_SHEET_LAYERS[0], + "route", + bbox.join(","), + ); + contours = prepareLayer(collection, createNormalizer(meta), ["등고수치"]); + } catch { + contours = null; + } + } + map.update({ meta, image, contours }); + } + + async function loadSurfaces(): Promise { + try { + surfaces = await fetchYearSurfaces(projectId); + } catch (error) { + showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); + } + } + + async function loadAnalysis(): Promise { + try { + analysis = await fetchPlanAnalysis(projectId, sentPoints()); + startM = analysis.start_chainage_m; + targetM = analysis.end_chainage_target_m ?? null; + serverEdits = Object.fromEntries( + analysis.years.flatMap((entry) => (entry.edit ? [[entry.year, entry.edit]] : [])), + ); + edits = + draftEdits ?? + Object.fromEntries( + Object.entries(serverEdits).map(([year, { metrics: _m, ...edit }]) => [year, edit]), + ); + // 분석이 바뀐 연도(지문 다름)의 고침은 버림 + const prints = new Map(analysis.years.map((e) => [e.year, e.analysis?.fingerprint])); + const stale = Object.keys(edits) + .map(Number) + .filter((year) => prints.get(year) !== edits[year].base_fingerprint); + if (stale.length) { + edits = without(edits, stale); + if (draftEdits) + showToast(`분석이 바뀌어 ${stale.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info"); + } + draftEdits = edits; + } catch (error) { + analysis = null; + showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error"); + } + } + + /** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ + async function watch(kind: "terrain" | "analyze"): Promise { + busy = kind; + bar.hidden = false; + refresh(); + try { + for (;;) { + if (!root.isConnected) return null; + const job = (await fetchPlanProgress(projectId))[kind]; + bar.value = job.percent; + terrainText.textContent = `${kind === "terrain" ? "지형 받기" : "분석"} ${job.percent}% · ${job.message ?? ""}`; + if (!job.running) { + if (job.error) showToast(job.error, "error"); + return job; + } + await sleep(POLL_MS); + } + } catch (error) { + // 진행을 못 읽음(세션 끊김 등) — 서버 일은 그대로 돎 · 다시 열면 이어 지켜봄 + showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error"); + return null; + } finally { + busy = null; + bar.hidden = true; + refresh(); + } + } + + async function run(kind: "terrain" | "analyze"): Promise { + try { + await startPlanJob(projectId, kind, sentPoints()); + } catch (error) { + // 이미 도는 일(다른 창 · D02 [>])이면 그것을 지켜봄 + if (!/이미 돌고/.test(errorText(error))) { + showToast(errorText(error), "error"); + return null; + } + } + return watch(kind); + } + + async function download(): Promise { + const job = await run("terrain"); + if (!job || !root.isConnected) return; + await Promise.all([loadSurfaces(), loadBackground()]); + refresh(); + } + + async function analyze(): Promise { + const job = await run("analyze"); + if (!job || !root.isConnected) return; + await loadAnalysis(); + refresh(); + } + + async function save(): Promise { + const picked = Object.fromEntries( + rows.filter((row) => row.mode).map((row) => [row.year, row.mode as string]), + ); + const sent = Object.fromEntries( + results.flatMap((result, i) => { + const edit = editOf(result, rows[i].mode); + return edit ? [[result.year, edit]] : []; + }), + ); + showLoadingOverlay(); + try { + const began = performance.now(); + await savePlanAnalysis(projectId, picked, sentPoints(), sent); + lastSaveMs = Math.round(performance.now() - began); + clearState("d03-plan", projectId); + draftEdits = undefined; + history = []; + await loadPoints(); + await loadAnalysis(); + refresh(); + showToast("예정노선을 저장했습니다.", "success"); + } catch (error) { + showToast(`저장하지 못했습니다 — ${errorText(error)}`, "error"); + } finally { + hideLoadingOverlay(); + } + } + + function open3d(): void { + const items = rows.flatMap((row, i) => { + const surface = surfaces.get(row.year); + const line = row.mode ? results[i].routes[row.mode].planned : undefined; + if (!surface || !line) return []; + const stations = stationChainages([row], PLAN_STATION_INTERVAL_M).map((chainage) => ({ + chainage, + localM: chainage - row.start_chainage_m, + })); + return [{ year: row.year, modelId: surface.modelId, grid: surface.grid, line, stations }]; + }); + const first = selected !== null ? rowAt(rows, selected)?.year : undefined; + openPlan3d(projectId, items, first ?? items[0]?.year ?? 0); + } + + async function loadPoints(): Promise { + const view = await fetchPlanPoints(projectId); + const empty = view.years.find((entry) => !entry.start || !entry.end); + if (empty) { + terrainText.textContent = `${empty.year}년 지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.`; + return false; + } + years = view.years.map((entry) => entry.year); + savedNodes = nodesOf( + view.years.map((entry) => ({ + start: [entry.start!.x, entry.start!.y] as Vertex, + end: [entry.end!.x, entry.end!.y] as Vertex, + })), + ); + const draft = readState("d03-plan", projectId); + nodes = draft?.nodes?.length === savedNodes.length ? draft.nodes : savedNodes; + choices = draft?.choices ?? {}; + draftEdits = draft?.edits; + return true; + } + + // 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디 · 꺾임점 고침 · 저장 시간(ORCA 판정) + (window as unknown as { __d03Plan?: unknown }).__d03Plan = { + stationScreen: map.stationScreen, + screenOf: map.screenOf, + rows: () => rows, + nodes: () => nodes, + pick: onStation, + cross: () => lastCross, + edits: () => edits, + targets: () => editLayer.targets(), + results: () => results, + saveMs: () => lastSaveMs, + // 가이드 그림 · 검증 — 첫 고침 연도 마디 하나를 (dx, dy) m 옮김(끌기와 같은 길) + nudge: (index: number, dx: number, dy: number) => { + const target = editLayer.targets()[0]; + const node = target?.edit.nodes[index]; + if (!node) return; + const step = moveNode(target.edit, index, [node[0] + dx, node[1] + dy], target.minRadiusM); + if ("blocked" in step) return showToast(step.blocked, "info"); + remember(); + onEdit(target.year, step.edit, true); + }, + }; + const onKey = (event: KeyboardEvent): void => { + if (!root.isConnected) return window.removeEventListener("keydown", onKey); + const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select"); + if (editable && !typing && (event.ctrlKey || event.metaKey) && event.key === "z") { + event.preventDefault(); + undo(); + } + }; + window.addEventListener("keydown", onKey); + + try { + editable = !!(await fetchSessionUser())?.is_forest_service; + if (!(await loadPoints())) return; + await Promise.all([loadSurfaces(), loadAnalysis()]); + refresh(); + map.fit(); + await loadBackground(); + const progress = await fetchPlanProgress(projectId); + if (progress.analyze.running) await analyze(); + if (progress.terrain.running) await download(); + else if (editable && reach().some((ok) => !ok)) await download(); + } catch (error) { + terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; + } +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css new file mode 100644 index 000000000..fc5773e7a --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -0,0 +1,129 @@ +/* D03 예정노선 2단계 분석(PLAN 530-3) — 평면이 본문을 채우고 아래 횡단 판 · 좌측 연도 카드. */ + +.d03-plan-layout .ui-workflow-layout__main { + height: calc(100dvh - var(--app-header-h)); + overflow: hidden; +} + +.d03-plan { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.d03-plan__map { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + background: #1f2937; +} + +.d03-plan__map canvas { + display: block; + touch-action: none; +} + +.d03-plan__cross { + position: relative; + flex: 0 0 240px; + display: flex; + flex-direction: column; + border-top: 1px solid var(--color-border); + background: var(--color-surface); +} + +.d03-plan__cross.is-collapsed { + flex-basis: 0; +} + +.d03-plan__cross.is-collapsed > :not(.ui-workflow-overlay__handle) { + display: none; +} + +.d03-plan__cross > .ui-workflow-overlay__handle { + position: absolute; + top: -28px; + left: 50%; + transform: translateX(-50%); +} + +.d03-plan__cross-head { + padding: 6px 12px 0; + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.d03-plan__cross-body { + flex: 1 1 auto; + min-height: 0; + padding: 4px 12px 8px; +} + +.d03-plan__cross-body p { + margin: 12px 0; + color: var(--color-text-secondary); +} + +.d03-plan__hint { + margin: 0 0 6px; + font-size: 0.85rem; + color: var(--color-text-secondary); +} + +.d03-plan__bar { + width: 100%; +} + +.d03-plan__years { + display: flex; + flex-direction: column; + gap: 10px; +} + +.d03-plan__year { + display: flex; + flex-direction: column; + gap: 4px; +} + +.d03-plan__year-head { + font-weight: 600; + font-size: 0.85rem; +} + +.d03-plan__mode { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 6px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); + color: var(--color-text); + font-size: 0.8rem; + text-align: left; + cursor: pointer; +} + +.d03-plan__mode.is-picked { + border-color: var(--color-primary); + box-shadow: inset 0 0 0 1px var(--color-primary); +} + +.d03-plan__mode:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.d03-plan__mode span { + color: var(--color-text-secondary); +} + +.d03-plan__3d-top { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} diff --git a/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts b/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts new file mode 100644 index 000000000..c7983eacf --- /dev/null +++ b/D03_PlanAnalysis/draft/D03_PlanAnalysis_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * D03_PlanAnalysis_Route.ts + * 길 선언(PLAN 529-1 · 530-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = 분석 화면 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D03_PLAN_ANALYSIS, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../D03_PlanAnalysis_UI_Page")).renderD03PlanAnalysis, +}; diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts new file mode 100644 index 000000000..afa56b53e --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts @@ -0,0 +1,173 @@ +/* ============================================================================= + * D03_PlanAnalysis_Edit.ts + * D 예정노선 꺾임점 고침 계산(PLAN 543-1) — 고른 갈래 노선의 마디(IP) 끌기 · 넣기 · 지우기 · 곡선 손잡이 + * = R. DOM · fetch · sessionStorage 없음. + * + * 셈은 B05 편집기 순수 부품 그대로(import 만) — 서버 노드 펴기 `flattenServerPlan` · 다시 그음 + * `rebuildLine`(R 하한 끌어올림 + `buildEditedPolyline`) · 마디 넣기 · 빼기 `spliceNode` · 손잡이 → + * 교각점 · R `dragHandleTo` · 하한 새로 깸 막기 `floorStop`. + * 처음 · 끝 마디 = 연도 경계 지점 → 잠금(옮기면 연도 사이가 끊김 · 지점은 지점 끌기로). + * + * ⚠ 서버 짝 — [저장] 때 `D03_PlanAnalysis_Edit.py` `edited_line` 이 같은 마디 · 켬끔 · R 로 선을 다시 + * 그음(Node `route.polyline` = 같은 `buildEditedPolyline` · 폴백 `build_planned_polyline`) · 거울 시험 + * `resources/tester/test_543_plan_edit.py`. + * ========================================================================== */ + +import { + flattenServerPlan, + floorStop, + rebuildLine, + spliceNode, + type CurveLimits, + type NodeColumns, +} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits"; +import { + dragHandleTo, + type EditedCurve, + type EditedPolyline, + type Vertex, +} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; +import type { PlanRoute } from "./D03_PlanAnalysis_Plan"; + +/** 연도 하나의 꺾임점 고침(캐시 · [저장] 입력) — 고친 갈래 · 그 분석 지문 · 마디 · 곡선 켬끔 · R. */ +export interface PlanEdit { + base_mode: string; + base_fingerprint: string; + nodes: Vertex[]; + curve_flags: boolean[]; + /** 넣은 R(하한까지 끌어올린 값) · null = 법정 최소 R. */ + radii: Array; +} + +export type EditStep = { edit: PlanEdit; line: EditedPolyline } | { blocked: string }; + +const limitsOf = (minRadiusM: number): CurveLimits => ({ + minRadiusM, + limitRadiusM: minRadiusM, + limitArcM: 0, +}); + +const columnsOf = (edit: PlanEdit): NodeColumns => ({ + planned: edit.nodes.map(([x, y]) => [x, y] as Vertex), + curveOn: [...edit.curve_flags], + curveRadius: [...edit.radii], + curveLock: edit.nodes.map(() => null), + curveArc: edit.nodes.map(() => null), + apexLock: edit.nodes.map(() => false), +}); + +/** 엔진 노선 → 고침 시작 마디(묶인 곡선은 교각점 하나로 · 서버가 고른 R 그대로). */ +export function editFromRoute(route: PlanRoute, mode: string, fingerprint: string): PlanEdit { + const flat = flattenServerPlan(route.nodes ?? [], (route.curves ?? []) as EditedCurve[]); + return { + base_mode: mode, + base_fingerprint: fingerprint, + nodes: flat.planned, + curve_flags: flat.curveOn, + radii: flat.curveRadius, + }; +} + +/** 마디 → 선 — R 은 하한까지 끌어올린 값으로 고침에 되적음(서버에 같은 값을 보냄). */ +export function buildEdit( + edit: PlanEdit, + minRadiusM: number, +): { edit: PlanEdit; line: EditedPolyline } { + const columns = columnsOf(edit); + const line = rebuildLine(columns, limitsOf(minRadiusM)); + return { edit: { ...edit, radii: columns.curveRadius }, line }; +} + +/** 고친 뒤 — 지키던 하한을 새로 깨면 막음(그 걸음 버림). */ +function settle(before: PlanEdit, after: PlanEdit, minRadiusM: number): EditStep { + const built = buildEdit(after, minRadiusM); + const stop = floorStop( + columnsOf(before), + built.edit.nodes, + built.line.nodes, + limitsOf(minRadiusM), + ); + return stop ? { blocked: stop } : built; +} + +const isEnd = (edit: PlanEdit, index: number): boolean => + index <= 0 || index >= edit.nodes.length - 1; +const ENDS_LOCKED = "처음 · 끝 마디는 연도 경계 지점이라 여기서 못 고칩니다."; + +/** 마디 끌기 — 처음 · 끝 잠금. */ +export function moveNode(edit: PlanEdit, index: number, to: Vertex, minRadiusM: number): EditStep { + if (isEnd(edit, index)) return { blocked: ENDS_LOCKED }; + const nodes = edit.nodes.map((node, i) => (i === index ? ([to[0], to[1]] as Vertex) : node)); + return settle(edit, { ...edit, nodes }, minRadiusM); +} + +/** 곡선 손잡이(접선점) 끌기 → 그쪽 직선이 돌고 교각점 · R 이 바뀜 · R 은 법정 최소 밑으로 안 내려감. */ +export function moveHandle( + edit: PlanEdit, + index: number, + which: "start" | "end", + to: Vertex, + minRadiusM: number, +): EditStep { + if (isEnd(edit, index)) return { blocked: ENDS_LOCKED }; + const got = dragHandleTo( + edit.nodes[index - 1], + edit.nodes[index], + edit.nodes[index + 1], + which, + to, + ); + if (!got) return { blocked: "곡선을 그 자리로 못 옮깁니다." }; + const nodes = edit.nodes.map((node, i) => (i === index ? got.apex : node)); + const radii = edit.radii.map((r, i) => (i === index ? Math.max(got.radius, minRadiusM) : r)); + const curve_flags = edit.curve_flags.map((on, i) => (i === index ? true : on)); + return settle(edit, { ...edit, nodes, radii, curve_flags }, minRadiusM); +} + +/** 마디 넣기 — `segment` 번째 마디와 다음 마디 사이. */ +export function insertNode( + edit: PlanEdit, + segment: number, + at: Vertex, + minRadiusM: number, +): EditStep { + if (segment < 0 || segment >= edit.nodes.length - 1) return { blocked: "선 위를 누르세요." }; + const columns = columnsOf(edit); + spliceNode(columns, segment + 1, [at[0], at[1]]); + const after = { + ...edit, + nodes: columns.planned, + curve_flags: columns.curveOn, + radii: columns.curveRadius, + }; + return settle(edit, after, minRadiusM); +} + +/** 마디 지우기 — 처음 · 끝 잠금. */ +export function removeNode(edit: PlanEdit, index: number, minRadiusM: number): EditStep { + if (isEnd(edit, index)) return { blocked: ENDS_LOCKED }; + const columns = columnsOf(edit); + spliceNode(columns, index); + const after = { + ...edit, + nodes: columns.planned, + curve_flags: columns.curveOn, + radii: columns.curveRadius, + }; + return settle(edit, after, minRadiusM); +} + +/** 같은 고침인가(서버 저장값과 견줌 · 1 mm · R 1 mm). */ +export function sameEdit(a: PlanEdit | null | undefined, b: PlanEdit | null | undefined): boolean { + if (!a || !b) return false; + const near = (x: number | null, y: number | null): boolean => + x === null || y === null ? x === y : Math.abs(x - y) < 1e-3; + return ( + a.base_mode === b.base_mode && + a.base_fingerprint === b.base_fingerprint && + a.nodes.length === b.nodes.length && + a.nodes.every((p, i) => near(p[0], b.nodes[i][0]) && near(p[1], b.nodes[i][1])) && + a.curve_flags.every((on, i) => on === b.curve_flags[i]) && + a.radii.every((r, i) => near(r, b.radii[i] ?? null)) + ); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts new file mode 100644 index 000000000..3a6c10e0c --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts @@ -0,0 +1,124 @@ +/* ============================================================================= + * D03_PlanAnalysis_Expect.ts + * D03 예상 횡단(PLAN 544-1 · 요구 363-1) — 측점 지반 → 표준 횡단 기본값 설계선 · 절 · 성토 ㎡. + * 셈 = 공용 `computeCrossDesign`(① 짝 `compute_cross_design` · 거울 `test_b06_cross_design_mirror.py`) + * · 기본 = B 초기 설계와 같음(리핑암 · 산쪽 절토 · 일반 측구 · 암 경계 = 서버 기본) · 계획고 = 측점 중심 지반고 + * · 미교차 = `extendUnclosed`(격자 있을 때) · 곡선 확폭 없음 · 보기만(저장 없음). + * 3D = 같은 단면을 B05 `buildCorridor` 가 읽는 측점 꼴로(`expectSections`). + * 시험 `resources/tester/test_544_1_plan_expect.py`. + * ========================================================================== */ + +import { + computeCrossDesign, + type CrossDesignOptions, + type CrossDesignResult, + type StandardCrossSectionSpec, +} from "../../common_util/common_util_cross_design"; +import type { DtmGrid } from "../../common_util/common_util_surface_sampler"; +import { + extendUnclosed, + type CrossGroundPoint, + type StationGround, +} from "../../B05_Profile/logic/B05_Profile_Sections_Ground"; + +/** 서버 `sections/context` 의 표준 횡단(config) · 암 경계 기본 오프셋. */ +export interface ExpectStandard { + standard: StandardCrossSectionSpec; + rockBoundaryM: number | null; +} + +export interface ExpectCross { + design: CrossDesignResult; + /** 설계에 쓴 지반 표본(미교차로 넓혔으면 넓힌 것). */ + samples: CrossGroundPoint[]; +} + +/** B 초기 설계 기본 — 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구. */ +export function expectOptions( + uphill: "left" | "right" | null, + std: ExpectStandard, +): CrossDesignOptions { + return { + groundType: "ripping_rock", + sectionMode: `${uphill ?? "left"}_cut`, + ditchType: "standard", + standard: std.standard, + rockBoundaryOffsetM: std.rockBoundaryM, + }; +} + +/** 측점 하나 예상 단면 — 지반고 · 표본이 없으면 null. */ +export function expectCross( + station: StationGround, + std: ExpectStandard, + grid?: DtmGrid | null, +): ExpectCross | null { + const elevation = station.center_z; + if (elevation === null) return null; + const options = expectOptions(station.uphill_side, std); + try { + let samples = station.samples; + let design = computeCrossDesign(samples, elevation, options); + const wide = + design.slope_unclosed && grid + ? extendUnclosed( + samples, + [station.center_x, station.center_y], + station.left_xy, + (next) => computeCrossDesign(next, elevation, options), + grid, + ) + : null; + if (wide) { + samples = wide.samples as CrossGroundPoint[]; + design = computeCrossDesign(samples, elevation, options); + } + return { design, samples }; + } catch { + return null; // 표본 부족 — B 와 같은 태도(그 측점 건너뜀) + } +} + +/** B06 `CrossSection` 중 `buildCorridor` 가 읽는 칸(B06 형 파일은 별칭 import 라 여기 안 끌어옴). */ +export interface ExpectSection { + station_id: string; + chainage_m: number; + label: string; + kind: "regular"; + center_z: number | null; + azimuth_deg: null; + center_x: number; + center_y: number; + uphill_side: "left" | "right" | null; + frame: { left_xy: [number, number] }; + samples: CrossGroundPoint[]; + design: CrossDesignResult; +} + +/** 3D 코리도 입력 — 측점마다 예상 단면(못 세운 측점 뺌) · 누가 = 연도 노선 위 누가. */ +export function expectSections( + stations: StationGround[], + std: ExpectStandard, + grid?: DtmGrid | null, +): ExpectSection[] { + return stations.flatMap((station) => { + const cross = expectCross(station, std, grid); + if (!cross) return []; + return [ + { + station_id: String(station.chainage_m), + chainage_m: station.chainage_m, + label: "", + kind: "regular", + center_z: station.center_z, + azimuth_deg: null, + center_x: station.center_x, + center_y: station.center_y, + uphill_side: station.uphill_side, + frame: { left_xy: station.left_xy }, + samples: cross.samples, + design: cross.design, + }, + ]; + }); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts new file mode 100644 index 000000000..445992c7e --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -0,0 +1,232 @@ +/* ============================================================================= + * D03_PlanAnalysis_Plan.ts + * D 예정노선 2단계 분석 화면 계산(PLAN 530-3) — 갈래 고름 · 이은 누가 · 이은 중심선 · 측점 · + * 지점(연도 경계) · 받은 지형 범위 판정. DOM · fetch · sessionStorage 없음. + * + * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` · `with_edits` ↔ + * `pickMode` · `chainRows` · `joinedLine` · `withEdits`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험 + * `resources/tester/test_530_3_plan_logic.py`. 화면은 고르는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. + * ========================================================================== */ + +export type Vertex = [number, number]; + +/** 짝: `D03_PlanAnalysis_Analyze.PLAN_MODES`(follow 뺌). */ +export const PLAN_MODES = ["gradeline", "earthwork", "gentle", "weighted"] as const; +export type PlanMode = (typeof PLAN_MODES)[number]; + +/** 짝: `D03_PlanAnalysis_Terrain.PLAN_CORRIDOR_M` — 지점이 연도 DEM 가장자리에서 이만큼 안이어야 + * 탐색 복도가 받은 지형 안에 듦(DEM = 구간 상자 + 복도 + 50 m). */ +export const PLAN_REACH_M = 300; + +export interface RouteMetrics { + length_m: number; + max_grade_pct?: number; + over_limit?: { total: number }; +} + +export interface PlanRoute { + planned?: Vertex[]; + /** 엔진 노드 · 곡선(B05 `RouteNode` · `RouteCurve` 꼴) — 꺾임점 고침의 시작 마디. */ + nodes?: Array<{ + x: number; + y: number; + radius_m: number | null; + inner_angle_deg: number | null; + violations?: string[]; + }>; + curves?: unknown[]; + metrics?: RouteMetrics; + /** 꺾임점을 고친 노선(`withEdits`). */ + edited?: boolean; + error?: string; +} + +export interface YearAnalysis { + year: number; + start: Vertex; + end: Vertex; + /** 분석 입력 지문 — 꺾임점 고침이 이 분석 위에서 한 것인가. */ + fingerprint?: string; + criteria?: { min_radius_m: number; max_grade_pct?: number }; + routes: Record; +} + +/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */ +export interface RouteEdit { + mode: string; + planned: Vertex[]; + metrics?: RouteMetrics | null; +} + +export interface ChainRow { + year: number; + mode: string | null; + length_m: number; + start_chainage_m: number; + end_chainage_m: number; +} + +const round3 = (value: number): number => Number(value.toFixed(3)); + +/** 고른 갈래 — 없거나 빈 갈래면 차례대로 첫 성공 갈래. */ +export function pickMode(result: YearAnalysis, wanted: string | null | undefined): string | null { + const routes = result.routes ?? {}; + const ok = (mode: string): boolean => mode in routes && !("error" in routes[mode]); + if (wanted && ok(wanted)) return wanted; + return PLAN_MODES.find(ok) ?? null; +} + +/** 연도별 고른 갈래 · 시작 · 끝 누가(이어 셈) — 앞 연도 끝 누가 = 다음 연도 시작 누가. */ +export function chainRows( + results: ReadonlyArray, + choices: Readonly>, + startChainageM: number, +): ChainRow[] { + let at = startChainageM || 0; + return results.map((result) => { + const mode = pickMode(result, choices[result.year]); + const length = mode ? Number(result.routes[mode].metrics?.length_m ?? 0) : 0; + const row = { + year: result.year, + mode, + length_m: round3(length), + start_chainage_m: round3(at), + end_chainage_m: round3(at + length), + }; + at += length; + return row; + }); +} + +/** 선 길이(m) — 짝: `D03_PlanAnalysis_Analyze.line_length`(같은 차례로 더함). */ +export function lineLength(line: ReadonlyArray): number { + let total = 0; + for (let i = 1; i < line.length; i += 1) + total += Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]); + return total; +} + +/** 고친 노선을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 길이(이은 누가 · 측점이 따라감). + * 그 갈래가 없거나 빈 갈래면 그대로. */ +export function withEdits( + results: ReadonlyArray, + edits: Readonly>, +): YearAnalysis[] { + return results.map((result) => { + const edit = edits[result.year]; + const route = edit ? result.routes?.[edit.mode] : undefined; + if (!edit || !route || "error" in route) return result; + const metrics = { ...(edit.metrics ?? {}), length_m: lineLength(edit.planned) }; + return { + ...result, + routes: { + ...result.routes, + [edit.mode]: { ...route, planned: edit.planned, metrics, edited: true }, + }, + }; + }); +} + +const samePoint = (a: Vertex, b: Vertex): boolean => + Math.abs(a[0] - b[0]) < 1e-3 && Math.abs(a[1] - b[1]) < 1e-3; + +/** 고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채). */ +export function joinedLine( + results: ReadonlyArray, + rows: ReadonlyArray, +): Vertex[] { + const line: Vertex[] = []; + results.forEach((result, i) => { + const mode = rows[i]?.mode; + if (!mode) return; + const planned = result.routes[mode].planned ?? []; + const head = line.length && planned.length && samePoint(line[line.length - 1], planned[0]); + line.push(...(head ? planned.slice(1) : planned)); + }); + return line; +} + +/** 측점 누가(오름차순) — 연도 경계마다(경계점 = 측점) + 간격 배수 + 끝. 노선 없는 연도는 건너뜀. */ +export function stationChainages(rows: ReadonlyArray, intervalM: number): number[] { + const out: number[] = []; + const push = (value: number): void => { + if (!out.length || value - out[out.length - 1] > 1e-6) out.push(round3(value)); + }; + for (const row of rows) { + if (!row.mode) continue; + push(row.start_chainage_m); + let next = Math.floor(row.start_chainage_m / intervalM + 1e-9) * intervalM + intervalM; + for (; next < row.end_chainage_m - 1e-6; next += intervalM) push(next); + push(row.end_chainage_m); + } + return out; +} + +/** 측점 그리기 · 집기용 선 — 첫 점 앞에 첫 구간 방향으로 `startM` 만큼 뒤로 뺀 점을 붙여 선 위 누가 = + * 등록 시작 누가부터(`drawStationTicks` · `stationAtScreen` 은 선 첫 점을 0 으로 셈). */ +export function leadInLine(line: ReadonlyArray, startM: number): Vertex[] { + if (line.length < 2 || !(startM > 0)) return [...line]; + const [a, b] = line; + const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; + const back: Vertex = [ + a[0] - ((b[0] - a[0]) / length) * startM, + a[1] - ((b[1] - a[1]) / length) * startM, + ]; + return [back, ...line]; +} + +/** 누가가 든 연도 행(경계는 앞 연도) — 노선 있는 연도만 · 밖이면 null. */ +export function rowAt(rows: ReadonlyArray, chainageM: number): ChainRow | null { + return ( + rows.find( + (row) => + row.mode && + chainageM >= row.start_chainage_m - 1e-6 && + chainageM <= row.end_chainage_m + 1e-6, + ) ?? null + ); +} + +/** 연도별 지점 → 마디 P0…Pn(직전 종점 = 다음 시점). */ +export function nodesOf(years: ReadonlyArray<{ start: Vertex; end: Vertex }>): Vertex[] { + return years.length ? [years[0].start, ...years.map((year) => year.end)] : []; +} + +/** 마디 → 연도별 지점(서버 `points` 꼴). */ +export function pointsOf( + years: ReadonlyArray, + nodes: ReadonlyArray, +): Array<{ year: number; start: Vertex; end: Vertex }> { + return years.map((year, i) => ({ year, start: nodes[i], end: nodes[i + 1] })); +} + +export interface Box { + x0: number; + y0: number; + x1: number; + y1: number; +} + +/** 마디마다 받은 지형 안인가 — 그 마디를 끝점으로 쓰는 연도(앞 · 뒤) DEM 상자를 `reachM` 만큼 줄인 안. + * 연도 DEM 이 없으면 밖. */ +export function nodesInReach( + nodes: ReadonlyArray, + boxes: ReadonlyArray, + reachM: number = PLAN_REACH_M, +): boolean[] { + const inside = (point: Vertex, box: Box | null): boolean => + !!box && + point[0] >= box.x0 + reachM && + point[0] <= box.x1 - reachM && + point[1] >= box.y0 + reachM && + point[1] <= box.y1 - reachM; + return nodes.map((point, i) => { + const years = [i - 1, i].filter((year) => year >= 0 && year < boxes.length); + return years.every((year) => inside(point, boxes[year])); + }); +} + +/** 마디가 정본과 같은가(1 mm — 짝: `D02_PlanBase_Router.SAME_M`). */ +export function sameNodes(a: ReadonlyArray, b: ReadonlyArray): boolean { + return a.length === b.length && a.every((point, i) => samePoint(point, b[i])); +} diff --git a/D04_PlanConfirm/D04_PlanConfirm_Email.py b/D04_PlanConfirm/D04_PlanConfirm_Email.py new file mode 100644 index 000000000..09d68aeb9 --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_Email.py @@ -0,0 +1,50 @@ +"""D04 예정노선 확정 메일 — 업체에 그 연도 노선 묶음(zip)을 첨부해 보낸다 (요구 349-9).""" + +from __future__ import annotations + +import html + +from common_util.common_util_email import Attachment, send_email + + +def build_completion_mail( + *, + project_name: str, + region: str | None, + year: int, + length_m: float, + recipient: str, + attachment_names: list[str], +) -> tuple[str, str]: + """(제목, HTML 본문) — 프로젝트명 · 연도 · 연장 · 첨부 목록.""" + esc = html.escape + rows = [ + ("프로젝트", project_name), + ("사업 지역", region or "-"), + ("연도", f"{year}년"), + ("연장", f"{length_m / 1000:,.3f} km ({length_m:,.1f} m)"), + ] + table = "".join( + f"{esc(k)}" + f"{esc(v)}" + for k, v in rows + ) + files = "".join(f"
  • {esc(name)}
  • " for name in attachment_names) + body = f""" + + +

    안녕하세요, {esc(recipient)} 담당자님.

    +

    예정노선이 확정되어 {year}년 노선 파일을 보내 드립니다.

    +{table}
    +

    첨부 파일

    +
      {files}
    +

    압축을 풀면 shapefile(.shp · .shx · .dbf · .prj · .cpg · 좌표계 UTM-K)이 들어 있으며, +임도 설계 프로그램의 노선 입력에 그대로 올릴 수 있습니다.

    +""" + return f"[예정노선 확정] {project_name} {year}년", body + + +async def send_completion_mail( + *, to_email: str, subject: str, body: str, attachments: list[Attachment] +) -> bool: + return await send_email(to_email=to_email, subject=subject, html=body, attachments=attachments) diff --git a/D04_PlanConfirm/D04_PlanConfirm_Export.py b/D04_PlanConfirm/D04_PlanConfirm_Export.py new file mode 100644 index 000000000..633d98630 --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_Export.py @@ -0,0 +1,279 @@ +"""D04 예정노선 내보내기 — 연도별 노선(꺾은선)을 용화 예정노선 양식 shapefile 한 벌로 만든다. + +양식 = `resources/knowledge/original/실무문서/2026년 임도 용화/예정노선/대상지_일월.용화.2.2.*` +(분석 345-C): 2D 폴리라인 1레코드 · UTM-K(EPSG 5179) · cp949 · 칸 `대상지` C(20) · +`길이` N(11,3) km + 덧칸 `연도` N(4) · `시점누가` · `종점누가` N(11,2) m. +`.prj` 는 용화 원문 그대로 · `.cpg` 는 `949`. + +계산 자리 ③ 서버 단독 — 화면이 만지지 않는다. 순수 함수(값 → 바이트)만 두고 +파일은 임시 폴더에서만 쓴다. + +짝: `station_label` ↔ `common_util/common_util_station.ts` `stationLabel(…, 1)` +(거울 시험 `test_545_1_station_label_mirror.py`). +""" + +from __future__ import annotations + +import io +import math +import re +import tempfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path + +import geopandas as gpd +from openpyxl import Workbook +from pyproj import Transformer +from shapely.geometry import LineString + +EXPORT_EPSG = 5179 +SHP_SUFFIXES = (".shp", ".shx", ".dbf", ".prj", ".cpg") +MIME_ZIP = "application/zip" +MIME_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + +# 용화 `.prj` 원문(ESRI WKT) 그대로 — 양식 일치(시험이 용화 파일과 바이트 비교). +_YONGHWA_PRJ = ( + 'PROJCS["PCS_ITRF2000_UTM_K",GEOGCS["GCS_ITRF_2000",DATUM["D_ITRF_2000",' + 'SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],' + 'UNIT["Degree",0.0174532925199433]],PROJECTION["Transverse_Mercator"],' + 'PARAMETER["False_Easting",1000000.0],PARAMETER["False_Northing",2000000.0],' + 'PARAMETER["Central_Meridian",127.5],PARAMETER["Scale_Factor",0.9996],' + 'PARAMETER["Latitude_Of_Origin",38.0],UNIT["METER",1.0]]' +) +_SCHEMA = { + "geometry": "LineString", + "properties": { + "대상지": "str:20", + "길이": "float:11.3", + "연도": "int:4", + "시점누가": "float:11.2", + "종점누가": "float:11.2", + }, +} +_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\r\n\t]') +STATION_INTERVAL_M = 20.0 # D03 `PLAN_STATION_INTERVAL_M` 와 같음 + + +def station_label(chainage_m: float, interval_m: float = STATION_INTERVAL_M) -> str: + """측점 표기 `61+14.5` — TS `stationLabel(chainage, interval, 1)` 짝(소수 한 자리 · 반올림 위로).""" + safe = interval_m if interval_m > 0 else 1.0 + number = math.floor((chainage_m + 1e-6) / safe) + rest = chainage_m - number * safe + if abs(rest) < 0.05: + rest = 0.0 + if rest >= safe - 0.05: + number, rest = number + 1, 0.0 + tenth = math.floor(rest * 10 + 0.5) / 10 + return f"{number}+{int(tenth) if tenth == int(tenth) else tenth}" + + +@dataclass +class YearRoute: + """한 연도의 노선 — `points` 는 `crs_epsg` 좌표의 (x, y) 꺾은선(시점 → 종점). + + 누가거리 두 값이 있으면 그대로 칸에 쓰고(D03 이은 값), 없으면 연도 순서로 이어 셈. + """ + + year: int + points: list[tuple[float, float]] + crs_epsg: int + start_station_m: float | None = None + end_station_m: float | None = None + + +@dataclass +class ExportBundle: + """`by_year[연도]` = 그 연도 shp 한 벌 + 좌표표 · `all` = 전 연도 이은 한 벌 + 좌표표.""" + + by_year: dict[int, dict[str, bytes]] = field(default_factory=dict) + all: dict[str, bytes] = field(default_factory=dict) + lengths_m: dict[int, float] = field(default_factory=dict) + + +def safe_name(text: str) -> str: + """파일명에 못 쓰는 글자를 `_` 로.""" + return _BAD_FILENAME.sub("_", text).strip() or "_" + + +def polyline_length_m(points: list[tuple[float, float]]) -> float: + return sum(math.dist(a, b) for a, b in zip(points, points[1:], strict=False)) + + +def to_utmk(points: list[tuple[float, float]], crs_epsg: int) -> list[tuple[float, float]]: + """프로젝트 좌표 → UTM-K(5179). 같은 좌표계면 그대로.""" + pts = [(float(p[0]), float(p[1])) for p in points] + if crs_epsg == EXPORT_EPSG: + return pts + tf = Transformer.from_crs(crs_epsg, EXPORT_EPSG, always_xy=True) + return [tf.transform(x, y) for x, y in pts] + + +def _clip_bytes(text: str, limit: int = 20) -> str: + """dbf 문자 칸(cp949 바이트) 한도에 글자 단위로 맞춤.""" + while len(text.encode("cp949", errors="replace")) > limit: + text = text[:-1] + return text + + +def _target_text(region: str, length_m: float) -> str: + return f"{region}.{length_m / 1000:.1f}" + + +def _write_shapefile( + points: list[tuple[float, float]], year: int | None, stations: tuple[float, float], region: str +) -> dict[str, bytes]: + """5179 꺾은선 → `.shp/.shx/.dbf/.prj/.cpg` 바이트 (파일명은 호출부가 붙임: 키 = 확장자).""" + length_m = polyline_length_m(points) + frame = gpd.GeoDataFrame( + { + "대상지": [_clip_bytes(_target_text(region, length_m))], + "길이": [round(length_m / 1000, 3)], + "연도": [year], + "시점누가": [round(stations[0], 2)], + "종점누가": [round(stations[1], 2)], + }, + geometry=[LineString(points)], + crs=EXPORT_EPSG, + ) + with tempfile.TemporaryDirectory() as tmp: + out = Path(tmp) / "r.shp" + frame.to_file( + out, driver="ESRI Shapefile", encoding="cp949", engine="fiona", schema=_SCHEMA + ) + out.with_suffix(".prj").write_text(_YONGHWA_PRJ, encoding="ascii") + out.with_suffix(".cpg").write_text("949", encoding="ascii") + return {suffix: out.with_suffix(suffix).read_bytes() for suffix in SHP_SUFFIXES} + + +def _coordinate_xlsx(project_name: str, rows: list[dict]) -> bytes: + """사람이 읽는 좌표표 — 연도별 시점 · 종점(UTM-K · 위경도) · 측점 · 누가 · 연장.""" + to_ll = Transformer.from_crs(EXPORT_EPSG, 4326, always_xy=True) + book = Workbook() + sheet = book.active + sheet.title = "좌표표" + sheet.append([f"{project_name} 예정노선 좌표표 (UTM-K EPSG:5179)"]) + sheet.append( + [ + "연도", + "시점 X", + "시점 Y", + "종점 X", + "종점 Y", + "시점 경도", + "시점 위도", + "종점 경도", + "종점 위도", + "시점 측점", + "종점 측점", + "시점누가(m)", + "종점누가(m)", + "연장(m)", + ] + ) + for row in rows: + (sx, sy), (ex, ey) = row["points"][0], row["points"][-1] + (slon, slat), (elon, elat) = to_ll.transform(sx, sy), to_ll.transform(ex, ey) + sheet.append( + [ + row["year"], + round(sx, 3), + round(sy, 3), + round(ex, 3), + round(ey, 3), + round(slon, 7), + round(slat, 7), + round(elon, 7), + round(elat, 7), + station_label(row["start_station"]), + station_label(row["end_station"]), + round(row["start_station"], 2), + round(row["end_station"], 2), + round(row["length_m"], 2), + ] + ) + buffer = io.BytesIO() + book.save(buffer) + return buffer.getvalue() + + +def _named(base: str, parts: dict[str, bytes]) -> dict[str, bytes]: + return {f"{base}{suffix}": data for suffix, data in parts.items()} + + +def build_export( + project_name: str, + region: str, + years: list[YearRoute], + first_station_m: float = 0.0, + with_xlsx: bool = True, +) -> ExportBundle: + """연도 노선들 → 연도별 한 벌 + 전체 한 벌(+ 좌표표). + + 누가거리 = `first_station_m` 에서 시작해 연도 순서로 이어 셈(직전 연도 종점 = 다음 연도 시점). + shp 안 측점은 파일마다 0 부터가 아니라 이 누가값을 칸으로만 보관(B03 은 칸을 무시). + """ + ordered = sorted(years, key=lambda y: y.year) + rows: list[dict] = [] + station = float(first_station_m) + for item in ordered: + points = to_utmk(item.points, item.crs_epsg) + if len(points) < 2: + raise ValueError(f"{item.year}년 노선 점이 2개 미만입니다.") + length_m = polyline_length_m(points) + if item.start_station_m is not None and item.end_station_m is not None: + start, end = float(item.start_station_m), float(item.end_station_m) + else: + start, end = station, station + length_m + rows.append( + { + "year": item.year, + "points": points, + "length_m": length_m, + "start_station": start, + "end_station": end, + } + ) + station = end + + bundle = ExportBundle() + region_name = safe_name(region) + xlsx_name = f"좌표표_{safe_name(project_name)}.xlsx" + for row in rows: + base = f"대상지_{region_name}.{row['length_m'] / 1000:.1f}_{row['year']}" + files = _named( + base, + _write_shapefile( + row["points"], row["year"], (row["start_station"], row["end_station"]), region + ), + ) + if with_xlsx: + files[xlsx_name] = _coordinate_xlsx(project_name, [row]) + bundle.by_year[row["year"]] = files + bundle.lengths_m[row["year"]] = row["length_m"] + + # 전체: 앞 연도 종점 = 다음 연도 시점이라 겹치는 점 하나는 뺌. + merged: list[tuple[float, float]] = [] + for row in rows: + merged.extend( + row["points"][1:] if merged and merged[-1] == row["points"][0] else row["points"] + ) + total_m = polyline_length_m(merged) + bundle.lengths_m[0] = total_m + base = f"대상지_{region_name}.{total_m / 1000:.1f}_전체" + bundle.all = _named( + base, + _write_shapefile(merged, None, (rows[0]["start_station"], rows[-1]["end_station"]), region), + ) + if with_xlsx: + bundle.all[xlsx_name] = _coordinate_xlsx(project_name, rows) + return bundle + + +def company_zip(project_name: str, year: int, files: dict[str, bytes]) -> tuple[str, bytes]: + """업체에 보낼 `<프로젝트명>_예정노선_<연도>.zip` (이름, 바이트).""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for name, data in files.items(): + archive.writestr(name, data) + return f"{safe_name(project_name)}_예정노선_{year}.zip", buffer.getvalue() diff --git a/D04_PlanConfirm/D04_PlanConfirm_Link.py b/D04_PlanConfirm/D04_PlanConfirm_Link.py new file mode 100644 index 000000000..ed03bac9f --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_Link.py @@ -0,0 +1,346 @@ +"""D 예정노선 → 임도 연결 (요구 345 · 요구서 2-3 · 3-5 · PLAN 531-3). + +- `POST /api/dashboard/plan-routes/{assignee_id}/link` — {mode: new | existing, project_id?} + new = 임도 프로젝트 새로(B02 저장소 함수) · existing = 고른 우리 회사 임도 프로젝트 +- `GET /api/projects/{id}/plan-source` — 임도 프로젝트가 이어 받은 예정노선 · 업데이트 있음 + +연결 = 그 연도 shp 한 벌(D04 내보내기 함수)을 B03 입력 `input/shp/` 에 원래 이름으로 + +`input_files` 행 + metadata.json(B03 분석 함수) — 파일로 올린 것과 같은 길. 입력이 다 차면 +B03 완료 판정(옛 결과 지우기 확인 · 단계 되돌림) 뒤 WF1. 지형은 업체가 올림. +연결 시점 복사 · 원본이 바뀌면 알림만(내용 지문 비교 · 자동 반영 없음). +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import shutil +import tempfile +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from B02_ProjRegister.B02_ProjRegister_Repository import create_project +from B03_FileInput.B03_FileInput_Engine_Analyze import analyze_input_metadata +from B03_FileInput.B03_FileInput_Repository import ( + create_input_file, + get_project_input_readiness, + get_project_storage_relative_path, + supersede_previous_input_files, +) +from B03_FileInput.B03_FileInput_Router_Helpers import ( + _ANALYSIS_RUNNING_MESSAGE, + OutputsWouldBeDiscarded, + _complete_file_input_if_ready, + _confirm_replace_response, + _missing_required_file_types, + _schedule_background_task, + _write_stage_metadata, +) +from B03_FileInput.B03_FileInput_Schema import UploadedFileResult +from B03_FileInput.B03_FileInput_Service_WF1 import trigger_wf1_analysis_and_email +from common_util.common_util_auth import require_company +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workflow_state import is_analysis_running, merge_stage_params +from config.config_db import get_db_pool +from D04_PlanConfirm.D04_PlanConfirm_Export import build_export +from D04_PlanConfirm.D04_PlanConfirm_Router import year_routes_from_rows + +router = APIRouter(prefix="/api/projects", tags=["D04 Plan Link"]) +dashboard_router = APIRouter(prefix="/api/dashboard", tags=["D04 Plan Link"]) + +ROUTE_DIR = "B03_FileInput/input/shp" +DESIGN_SPEEDS = (20, 30, 40) # B05 RouteSolveRequest 가 받는 값 + + +class LinkRequest(BaseModel): + mode: Literal["new", "existing"] + project_id: UUID | None = None + confirm_replace: bool = False + las_free: bool = False # B03 「LAS 없이 설계」(화면 기억값) — 입력 완료 판정에만 + + +def _parsed(route_json: Any) -> dict[str, Any] | None: + value = json.loads(route_json) if isinstance(route_json, str | bytes) else route_json + return value if isinstance(value, dict) else None + + +def route_fingerprint(route_json: Any) -> str | None: + """연도 노선 내용 지문(sha1) — 고른 중심선 점(mm 반올림)만 봄 · 같은 값이면 같은 지문.""" + body = _parsed(route_json) + planned = (body or {}).get("planned") + if not planned: + return None + points = [[round(float(p[0]), 3), round(float(p[1]), 3)] for p in planned] + return hashlib.sha1(json.dumps(points).encode()).hexdigest() + + +def update_available( + route_json: Any, source_fingerprint: str | None, confirmed: bool = True +) -> bool: + """연결 때 지문 ≠ 지금 지문 — 지금 노선이 비면(지점 바뀜 · 분석 전) 알리지 않음. + + 확정이 풀린 연도(D02 지점 바뀜 뒤 다시 [저장]만)도 알리지 않음 — 가져오기가 409 라서. + """ + now = route_fingerprint(route_json) + return confirmed and bool(source_fingerprint) and now is not None and now != source_fingerprint + + +async def _fetch_one(sql: str, args: tuple) -> dict[str, Any] | None: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute(sql, args) + return await cursor.fetchone() + + +async def load_assignee(assignee_id: int) -> dict[str, Any] | None: + """지정 행 + 예정노선 프로젝트 칸 + 그 연도 노선.""" + return await _fetch_one( + "SELECT a.id, a.project_id, a.year, a.company_id, a.linked_project_id," + " p.name, p.region, p.road_type, p.design_speed_kph, p.route_start_m," + " y.epsg, y.route_json, y.confirmed_at" + " FROM plan_route_assignees a" + " JOIN projects p ON p.id = a.project_id AND p.deleted_at IS NULL" + " LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year" + " WHERE a.id = %s", + (assignee_id,), + ) + + +async def load_road_project(project_id: str) -> dict[str, Any] | None: + return await _fetch_one( + "SELECT id, company_id, project_kind FROM projects WHERE id = %s AND deleted_at IS NULL", + (project_id,), + ) + + +async def record_link( + project_id: str, assignee: dict[str, Any], fingerprint: str | None, speed: Any +) -> None: + """원본 칸 · 연결 표시 — 한 임도 프로젝트는 한 연도에만 이어짐(옛 연결은 끊음).""" + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await connection.begin() + try: + await cursor.execute( + "UPDATE projects SET source_project_id = %s, source_copied_at = NOW()," + " source_fingerprint = %s, design_speed_kph = COALESCE(design_speed_kph, %s)" + " WHERE id = %s", + (assignee["project_id"], fingerprint, speed, project_id), + ) + await cursor.execute( + "UPDATE plan_route_assignees SET linked_project_id = NULL" + " WHERE linked_project_id = %s AND id <> %s", + (project_id, assignee["id"]), + ) + await cursor.execute( + "UPDATE plan_route_assignees SET linked_project_id = %s WHERE id = %s", + (project_id, assignee["id"]), + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + +def year_files(assignee: dict[str, Any]) -> dict[str, bytes]: + """그 연도 shp 한 벌(.shp .shx .dbf .prj .cpg) — 파일명 → 바이트.""" + row = {**assignee, "route_json": _parsed(assignee["route_json"])} + routes = year_routes_from_rows([row]) + bundle = build_export(assignee["name"], assignee["region"] or "", routes, with_xlsx=False) + return bundle.by_year[int(assignee["year"])] + + +async def _supersede_route_set(connection: aiomysql.Connection, project_id: str) -> None: + """옛 노선 세트(다른 이름 포함) 행을 내림 — 노선 칸은 새 한 벌만 봄.""" + async with connection.cursor() as cursor: + await cursor.execute( + "UPDATE input_files SET status = 'SUPERSEDED' WHERE project_id = %s" + " AND raw_file_path LIKE %s AND status IN ('UPLOADED', 'PROCESSED')", + (project_id, f"%{ROUTE_DIR}/%"), + ) + + +async def import_route_files( + project_id: str, + files: dict[str, bytes], + session: dict[str, Any], + confirm_replace: bool = False, + las_free: bool = False, +) -> dict[str, Any]: + """shp 한 벌 → B03 입력. 지울 결과가 있고 확인이 없으면 `OutputsWouldBeDiscarded`.""" + pid = UUID(project_id) + async with get_db_pool().acquire() as connection: + async with connection.cursor(aiomysql.DictCursor) as cursor: + if await is_analysis_running(cursor, project_id): + raise HTTPException(status_code=409, detail=_ANALYSIS_RUNNING_MESSAGE) + stored = await get_project_storage_relative_path(connection, pid) + project_root = Path(resolve_stored_project_path(stored)) + with tempfile.TemporaryDirectory() as tmp: + for name, data in files.items(): + (Path(tmp) / name).write_bytes(data) + metas = { + name: await asyncio.to_thread(analyze_input_metadata, Path(tmp) / name) + for name in files + } + await connection.begin() + try: + await _supersede_route_set(connection, project_id) + results: list[UploadedFileResult] = [] + for name, data in files.items(): + meta, relative = metas[name], f"{ROUTE_DIR}/{name}" + epsg = meta.get("epsg") + input_id = await create_input_file( + connection, + project_id=pid, + file_type=Path(name).suffix.lower().lstrip("."), + original_filename=name, + relative_path=relative, + file_size_bytes=len(data), + upload_by=int(session["user_id"]), + crs_epsg=int(epsg) if epsg is not None else None, + metadata=meta, + ) + await supersede_previous_input_files(connection, pid, name, input_id) + results.append( + UploadedFileResult( + input_file_id=input_id, + original_filename=name, + file_type=Path(name).suffix.lower().lstrip("."), + relative_path=relative, + size_bytes=len(data), + metadata=meta, + ) + ) + types, _, _ = await get_project_input_readiness(connection, pid) + wf1_input = None + if not _missing_required_file_types(types, las_free): + wf1_input = await _complete_file_input_if_ready( + connection, pid, las_free, confirm_replace + ) + target = project_root / ROUTE_DIR + target.mkdir(parents=True, exist_ok=True) + for name in files: + shutil.move(str(Path(tmp) / name), str(target / name)) + await connection.commit() + except Exception: + await connection.rollback() + raise + _write_stage_metadata(project_root / "B03_FileInput", pid, results) + if wf1_input is not None: + _schedule_background_task( + trigger_wf1_analysis_and_email( + project_id=pid, + input_file_id=wf1_input, + user_role=str(session["role"]), + started_by=session.get("user_id"), + ), + task_name=f"b04-preprocess-auto-{project_id}", + ) + return {"files": [r.original_filename for r in results], "analysis_started": bool(wf1_input)} + + +async def _new_road_project(assignee: dict[str, Any], session: dict[str, Any]) -> str: + """예정노선 이름 · 지역 · 임도 종류 · 그 연도로 임도 프로젝트(B02 등록과 같은 함수).""" + body = _parsed(assignee["route_json"]) or {} + created = await create_project( + user_id=int(session["user_id"]), + company_id=int(session["company_id"]), + name=f"{assignee['name']} {assignee['year']}", + region=assignee["region"] or "", + road_type=assignee["road_type"] or "work", + project_year=int(assignee["year"]), + estimated_length_m=body.get("length_m"), + memo=None, + ) + return str(created["project_id"]) + + +async def seed_design_speed(project_id: str, speed: Any) -> None: + """새 임도만 — 예정노선 설계속도를 단계 2(B05) params `options.design_speed_kph` 에. + + B05 페이지 설정 · 자동설계 체인(`B03_FileInput_Service_Chain`)이 읽는 자리 · 값만 넣음. + """ + if speed is None or int(speed) not in DESIGN_SPEEDS: + return + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await connection.begin() + await merge_stage_params( + cursor, project_id, 2, {"options": {"design_speed_kph": int(speed)}} + ) + await connection.commit() + + +@dashboard_router.post("/plan-routes/{assignee_id}/link", response_model=None) +async def link_plan_route( + assignee_id: int, payload: LinkRequest, session: dict[str, Any] = Depends(require_company) +) -> dict[str, Any] | JSONResponse: + """지정받은 연도 노선을 임도 프로젝트 B03 입력으로 — 지정 회사만.""" + assignee = await load_assignee(assignee_id) + if not assignee: + raise HTTPException(status_code=404, detail="지정 업체 행을 찾을 수 없습니다.") + if assignee["company_id"] is None or assignee["company_id"] != session.get("company_id"): + raise HTTPException(status_code=403, detail="이 예정노선을 지정받은 회사가 아닙니다.") + if not assignee["confirmed_at"] or route_fingerprint(assignee["route_json"]) is None: + raise HTTPException(status_code=409, detail="확정된 노선이 없습니다.") + if payload.mode == "existing": + if payload.project_id is None: + raise HTTPException(status_code=400, detail="연결할 임도 프로젝트를 고르세요.") + project = await load_road_project(str(payload.project_id)) + if not project: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if project["company_id"] != session.get("company_id"): + raise HTTPException(status_code=403, detail="다른 회사의 프로젝트입니다.") + if project["project_kind"] != "ROAD": + raise HTTPException(status_code=400, detail="임도설계 프로젝트만 연결할 수 있습니다.") + files = await asyncio.to_thread(year_files, assignee) + if payload.mode == "existing": + project_id = str(payload.project_id) + else: + project_id = await _new_road_project(assignee, session) + await seed_design_speed(project_id, assignee["design_speed_kph"]) + try: + result = await import_route_files( + project_id, files, session, payload.confirm_replace, payload.las_free + ) + except OutputsWouldBeDiscarded as exc: + return _confirm_replace_response(exc) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + fingerprint = route_fingerprint(assignee["route_json"]) + await record_link(project_id, assignee, fingerprint, assignee["design_speed_kph"]) + return {"status": "success", "project_id": project_id, "year": assignee["year"], **result} + + +@router.get("/{project_id}/plan-source", response_model=None) +async def plan_source(project_id: UUID) -> dict[str, Any]: + """임도 프로젝트가 이어 받은 예정노선(B03 머리 띠) — 없으면 linked False.""" + row = await _fetch_one( + "SELECT p.source_fingerprint, s.name AS plan_name, a.id AS assignee_id, a.year," + " y.route_json, y.confirmed_at" + " FROM projects p" + " JOIN projects s ON s.id = p.source_project_id AND s.project_kind = 'PLAN'" + " JOIN plan_route_assignees a ON a.linked_project_id = p.id" + " LEFT JOIN plan_route_years y ON y.project_id = a.project_id AND y.year = a.year" + " WHERE p.id = %s LIMIT 1", + (str(project_id),), + ) + if not row: + return {"status": "success", "linked": False} + return { + "status": "success", + "linked": True, + "assignee_id": row["assignee_id"], + "plan_name": row["plan_name"], + "year": row["year"], + "update_available": update_available( + row["route_json"], row["source_fingerprint"], row["confirmed_at"] is not None + ), + } diff --git a/D04_PlanConfirm/D04_PlanConfirm_Repository.py b/D04_PlanConfirm/D04_PlanConfirm_Repository.py new file mode 100644 index 000000000..83912fb8b --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_Repository.py @@ -0,0 +1,119 @@ +"""D04 확정 · 업체 지정 DB (Raw SQL · 표는 db_management/020_project_groups.sql).""" + +from __future__ import annotations + +import json +from typing import Any + +import aiomysql + +from config.config_db import get_db_pool + + +async def get_plan_project(project_id: str) -> dict[str, Any] | None: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT id, name, region, project_kind, route_start_m FROM projects" + " WHERE id = %s AND deleted_at IS NULL", + (project_id,), + ) + return await cursor.fetchone() + + +async def list_year_routes(project_id: str) -> list[dict[str, Any]]: + """연도 구간(오름차순) — `route_json` 은 파싱해서 돌려줌(없으면 None).""" + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT year, epsg, route_json, fingerprint, confirmed_at FROM plan_route_years" + " WHERE project_id = %s ORDER BY year", + (project_id,), + ) + rows = await cursor.fetchall() + for row in rows: + raw = row["route_json"] + row["route_json"] = json.loads(raw) if isinstance(raw, str | bytes) else raw + return rows + + +async def list_assignees(project_id: str) -> list[dict[str, Any]]: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT id, year, company_id, company_name, email, notified_at, linked_project_id" + " FROM plan_route_assignees WHERE project_id = %s ORDER BY year, id", + (project_id,), + ) + return await cursor.fetchall() + + +async def get_company_contact(company_id: int) -> dict[str, Any] | None: + """등록 회사 이름 + 대표 메일(회사 마스터 사용자).""" + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT c.id, c.name, u.email FROM companies c" + " LEFT JOIN users u ON u.id = c.master_user_id AND u.deleted_at IS NULL" + " WHERE c.id = %s AND c.deleted_at IS NULL", + (company_id,), + ) + return await cursor.fetchone() + + +async def apply_assignee_changes( + project_id: str, delete_ids: list[int], updates: list[dict], inserts: list[dict] +) -> None: + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await connection.begin() + try: + for assignee_id in delete_ids: + await cursor.execute( + "DELETE FROM plan_route_assignees WHERE id = %s AND project_id = %s", + (assignee_id, project_id), + ) + for row in updates: + await cursor.execute( + "UPDATE plan_route_assignees SET company_name = %s, email = %s" + " WHERE id = %s AND project_id = %s", + (row["company_name"], row["email"], row["id"], project_id), + ) + for row in inserts: + await cursor.execute( + "INSERT INTO plan_route_assignees" + " (project_id, year, company_id, company_name, email) VALUES (%s,%s,%s,%s,%s)", + (project_id, row["year"], row["company_id"], row["company_name"], row["email"]), + ) + await connection.commit() + except Exception: + await connection.rollback() + raise + + +async def mark_confirmed(project_id: str, years: list[int]) -> None: + if not years: + return + marks = ",".join(["%s"] * len(years)) + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + f"UPDATE plan_route_years SET confirmed_at = NOW() WHERE project_id = %s" + f" AND year IN ({marks})", + (project_id, *years), + ) + await connection.commit() + + +async def mark_notified(assignee_id: int) -> None: + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await cursor.execute( + "UPDATE plan_route_assignees SET notified_at = NOW() WHERE id = %s", (assignee_id,) + ) + await connection.commit() diff --git a/D04_PlanConfirm/D04_PlanConfirm_Router.py b/D04_PlanConfirm/D04_PlanConfirm_Router.py new file mode 100644 index 000000000..41897e5af --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_Router.py @@ -0,0 +1,322 @@ +"""D 예정노선 확정 · 업체 지정 API (요구 345 · PLAN 531-1 · 531-2). + +- `GET /api/projects/{id}/plan/assignees` — 연도별 지정 업체(지정 회사 = 자기 행만) +- `PUT /api/projects/{id}/plan/assignees` — 연도별 지정 업체 저장(등록 회사 또는 업체명 + 메일) +- `POST /api/projects/{id}/plan/finalize` — 연도 노선 확정 → 업체별 zip → 메일 첨부 → 발송 시각 +- `GET /api/projects/{id}/plan/summary` — D04 화면 연도 요약(지정 회사 = 자기 연도 · 자기 행만) +- `GET /api/projects/{id}/plan/export` — [내보내기] zip(산림청 = 전 연도 + 전체 · 공유 = 자기 연도) + +지정 · 확정은 산림청(시스템 관리자 또는 메일 *.go.kr)만. 지정받은 회사는 공용 접근 판정이 +GET 만 통과시킴. 계산 자리 ③ 서버 단독 — 내보내기 파일은 저장된 `route_json` 으로만 만듦. +""" + +from __future__ import annotations + +import asyncio +import io +import logging +import zipfile +from typing import Any +from urllib.parse import quote +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel + +from common_util.common_util_auth import is_forest_service, verify_session +from common_util.common_util_crs import tm_to_lonlat +from D04_PlanConfirm import D04_PlanConfirm_Repository as repo +from D04_PlanConfirm.D04_PlanConfirm_Email import build_completion_mail, send_completion_mail +from D04_PlanConfirm.D04_PlanConfirm_Export import ( + MIME_ZIP, + YearRoute, + build_export, + company_zip, + safe_name, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["D04 Plan Confirm"]) + + +class AssigneeIn(BaseModel): + year: int + company_id: int | None = None # 등록 회사 — 없으면 수기 업체 + company_name: str | None = None + email: str | None = None + + +class AssigneesRequest(BaseModel): + assignees: list[AssigneeIn] + years: list[int] | None = None # 맞출 연도(업체를 다 뺀 연도도) — 비우면 보낸 업체의 연도 + + +class FinalizeRequest(BaseModel): + years: list[int] | None = None # 비우면 분석이 저장된 모든 연도 + resend: bool = False # 이미 메일을 보낸 업체에도 다시 보냄 + + +def _require_forest(session: dict[str, Any]) -> None: + if not is_forest_service(session): + raise HTTPException( + status_code=403, detail="산림청만 예정노선을 확정 · 지정할 수 있습니다." + ) + + +def _key(row: dict[str, Any]) -> tuple: + """같은 업체 판정 — 등록 회사는 id · 수기 업체는 메일(대소문자 무시).""" + if row.get("company_id") is not None: + return (row["year"], "c", row["company_id"]) + return (row["year"], "e", (row.get("email") or "").strip().lower()) + + +def plan_assignee_changes( + existing: list[dict[str, Any]], wanted: list[dict[str, Any]], years: set[int] +) -> tuple[list[int], list[dict], list[dict]]: + """보낸 연도(`years`)만 맞춰 바꿈 — (지울 id, 고칠 행, 넣을 행). + + 같은 업체는 행을 살려 두어 발송 시각 · 연결한 프로젝트가 남는다. + """ + old = {_key(row): row for row in existing if row["year"] in years} + new = {_key(row): row for row in wanted} + delete_ids = [row["id"] for key, row in old.items() if key not in new] + updates = [{**row, "id": old[key]["id"]} for key, row in new.items() if key in old] + inserts = [row for key, row in new.items() if key not in old] + return delete_ids, updates, inserts + + +async def _resolve_assignee(item: AssigneeIn) -> dict[str, Any]: + """등록 회사면 이름 · 대표 메일을 DB 에서, 수기면 이름 + 메일을 검사.""" + email = (item.email or "").strip() + name = (item.company_name or "").strip() + if item.company_id is not None: + company = await repo.get_company_contact(item.company_id) + if not company: + raise HTTPException( + status_code=400, detail=f"등록 회사를 찾을 수 없습니다: {item.company_id}" + ) + return { + "year": item.year, + "company_id": item.company_id, + "company_name": company["name"], + "email": email or company.get("email") or None, + } + if not name or "@" not in email: + raise HTTPException(status_code=400, detail="업체명과 메일 주소가 필요합니다.") + return {"year": item.year, "company_id": None, "company_name": name, "email": email} + + +async def _plan_project(project_id: UUID) -> dict[str, Any]: + project = await repo.get_plan_project(str(project_id)) + if not project: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") + if project["project_kind"] != "PLAN": + raise HTTPException(status_code=400, detail="예정노선 프로젝트가 아닙니다.") + return project + + +def visible_to( + session: dict[str, Any], years: list[dict[str, Any]], assignees: list[dict[str, Any]] +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """산림청 = 전부 · 지정 회사 = 자기 회사가 지정된 연도 줄 · 자기 업체 행만(남의 이름 · 메일 뺌).""" + if is_forest_service(session): + return years, assignees + company = session.get("company_id") + mine = [a for a in assignees if a["company_id"] is not None and a["company_id"] == company] + own_years = {a["year"] for a in mine} + return [y for y in years if y["year"] in own_years], mine + + +@router.get("/{project_id}/plan/assignees", response_model=None) +async def get_plan_assignees( + project_id: UUID, session: dict = Depends(verify_session) +) -> dict[str, Any]: + await _plan_project(project_id) + _, assignees = visible_to(session, [], await repo.list_assignees(str(project_id))) + return {"status": "success", "assignees": assignees} + + +@router.put("/{project_id}/plan/assignees", response_model=None) +async def put_plan_assignees( + project_id: UUID, request: AssigneesRequest, session: dict = Depends(verify_session) +) -> dict[str, Any]: + """보낸 연도의 지정 업체를 그대로 맞춤 — 연도를 안 보낸 곳은 건드리지 않음.""" + _require_forest(session) + await _plan_project(project_id) + known = {row["year"] for row in await repo.list_year_routes(str(project_id))} + years = {a.year for a in request.assignees} | set(request.years or []) + unknown = sorted(years - known) + if unknown: + raise HTTPException(status_code=400, detail=f"예정노선 연도가 아닙니다: {unknown}") + wanted = [await _resolve_assignee(item) for item in request.assignees] + existing = await repo.list_assignees(str(project_id)) + delete_ids, updates, inserts = plan_assignee_changes(existing, wanted, years) + await repo.apply_assignee_changes(str(project_id), delete_ids, updates, inserts) + return {"status": "success", "assignees": await repo.list_assignees(str(project_id))} + + +def year_routes_from_rows(rows: list[dict[str, Any]]) -> list[YearRoute]: + """분석이 저장된 연도 → 내보내기 입력 (D03 `route_json["planned"]` = 프로젝트 좌표 꺾은선).""" + routes = [] + for row in rows: + body = row.get("route_json") + if not body or not body.get("planned"): + continue + routes.append( + YearRoute( + year=int(row["year"]), + points=[(p[0], p[1]) for p in body["planned"]], + crs_epsg=int(row["epsg"]), + start_station_m=body.get("start_chainage_m"), + end_station_m=body.get("end_chainage_m"), + ) + ) + return routes + + +def _result(assignee: dict[str, Any], state: str) -> dict[str, Any]: + return { + "year": assignee["year"], + "company_name": assignee["company_name"], + "email": assignee["email"], + "state": state, + } + + +@router.post("/{project_id}/plan/finalize", response_model=None) +async def finalize_plan( + project_id: UUID, request: FinalizeRequest, session: dict = Depends(verify_session) +) -> dict[str, Any]: + """연도 노선 확정 → 지정 업체마다 그 연도 zip 을 첨부해 메일 → 보낸 곳에 발송 시각.""" + _require_forest(session) + project = await _plan_project(project_id) + routes = year_routes_from_rows(await repo.list_year_routes(str(project_id))) + saved = {r.year for r in routes} + years = sorted(set(request.years) if request.years else saved) + missing = [y for y in years if y not in saved] + if not years or missing: + raise HTTPException( + status_code=409, detail=f"분석 [저장]이 안 된 연도입니다: {missing or '없음'}" + ) + await repo.mark_confirmed(str(project_id), years) + + # 누가거리가 연도마다 이어지므로 저장된 전 연도로 한 번에 만들고, 보낼 때만 연도를 고름. + first = float(project.get("route_start_m") or 0.0) + bundle = await asyncio.to_thread( + build_export, project["name"], project["region"] or "", routes, first + ) + results = [] + for assignee in await repo.list_assignees(str(project_id)): + year = assignee["year"] + if year not in years: + continue + if assignee["notified_at"] and not request.resend: + results.append(_result(assignee, "already_sent")) + continue + if not assignee["email"]: + results.append(_result(assignee, "no_email")) + continue + zip_name, zip_bytes = company_zip(project["name"], year, bundle.by_year[year]) + subject, body = build_completion_mail( + project_name=project["name"], + region=project["region"], + year=year, + length_m=bundle.lengths_m[year], + recipient=assignee["company_name"] or assignee["email"], + attachment_names=[zip_name], + ) + sent = await send_completion_mail( + to_email=assignee["email"], + subject=subject, + body=body, + attachments=[(zip_name, zip_bytes, MIME_ZIP)], + ) + if sent: + await repo.mark_notified(assignee["id"]) + results.append(_result(assignee, "sent" if sent else "failed")) + return {"status": "success", "confirmed_years": years, "notifications": results} + + +def _end_point(point: list[float], epsg: int) -> dict[str, float]: + lon, lat = tm_to_lonlat(float(point[0]), float(point[1]), epsg) + return {"x": float(point[0]), "y": float(point[1]), "lon": lon, "lat": lat} + + +def year_summary(row: dict[str, Any]) -> dict[str, Any]: + """연도 한 줄 — 분석 [저장]이 없으면 saved False 만.""" + body = row.get("route_json") or {} + planned = body.get("planned") or [] + out = {"year": int(row["year"]), "saved": len(planned) >= 2, "confirmed_at": None} + if row.get("confirmed_at"): + out["confirmed_at"] = str(row["confirmed_at"]) + if out["saved"]: + epsg = int(row["epsg"]) + out.update( + length_m=body.get("length_m"), + start_chainage_m=body.get("start_chainage_m"), + end_chainage_m=body.get("end_chainage_m"), + mode=body.get("mode"), + start=_end_point(planned[0], epsg), + end=_end_point(planned[-1], epsg), + ) + return out + + +@router.get("/{project_id}/plan/summary", response_model=None) +async def get_plan_summary( + project_id: UUID, session: dict = Depends(verify_session) +) -> dict[str, Any]: + project = await _plan_project(project_id) + rows = await repo.list_year_routes(str(project_id)) + years, assignees = visible_to( + session, [year_summary(row) for row in rows], await repo.list_assignees(str(project_id)) + ) + return {"status": "success", "name": project["name"], "years": years, "assignees": assignees} + + +def export_zip(bundle: Any, years: list[int], with_all: bool) -> bytes: + """연도마다 `<연도>/` · 전체는 `전체/` — 좌표표 이름이 연도마다 같아 폴더로 가름.""" + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: + for year in years: + for name, data in bundle.by_year[year].items(): + archive.writestr(f"{year}/{name}", data) + if with_all: + for name, data in bundle.all.items(): + archive.writestr(f"전체/{name}", data) + return buffer.getvalue() + + +@router.get("/{project_id}/plan/export", response_model=None) +async def export_plan(project_id: UUID, session: dict = Depends(verify_session)) -> Response: + """산림청 = 저장된 전 연도 + 전체 · 지정 회사 = 확정된 자기 연도만(전체 없음).""" + project = await _plan_project(project_id) + rows = await repo.list_year_routes(str(project_id)) + routes = year_routes_from_rows(rows) + if not routes: + raise HTTPException(status_code=409, detail="분석 [저장]이 된 연도가 없습니다.") + forest = is_forest_service(session) + if forest: + years = [r.year for r in routes] + else: + confirmed = {int(r["year"]) for r in rows if r.get("confirmed_at")} + mine = { + a["year"] + for a in await repo.list_assignees(str(project_id)) + if a["company_id"] is not None and a["company_id"] == session.get("company_id") + } + years = sorted({r.year for r in routes} & confirmed & mine) + if not years: + raise HTTPException(status_code=403, detail="받을 수 있는 연도가 없습니다.") + first = float(project.get("route_start_m") or 0.0) + bundle = await asyncio.to_thread( + build_export, project["name"], project["region"] or "", routes, first + ) + data = await asyncio.to_thread(export_zip, bundle, years, forest) + name = f"{safe_name(project['name'])}_예정노선.zip" + return Response( + content=data, + media_type=MIME_ZIP, + headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(name)}"}, + ) diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts new file mode 100644 index 000000000..6a3ee8416 --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts @@ -0,0 +1,336 @@ +/* ============================================================================= + * D04_PlanConfirm_UI_Page.ts + * D04 확정 · 업체 · 내보내기(요구서 345 3-4 · PLAN 531-3) — B05~B09 틀(group_workflow). + * 본문 = 연도 표(연장 · 시점 · 종점 · 측점 · 확정 · 지정 업체) · 연도마다 업체 여럿 + * (등록 회사 찾기 = 가입 화면 회사 검색 · 아니면 업체명 + 메일 수기). + * 좌측 = 확정할 연도 고르기 + [내보내기] [확정] [저장]. + * 조작 = 캐시 `d04-assignees` 즉시 · [저장] = PUT …/plan/assignees(연도 전부 맞춤) → 캐시 비움 · + * [확정] = (캐시 있으면 저장 먼저) POST …/plan/finalize → 잠금판 · [내보내기] = GET …/plan/export zip. + * 산림청이 아니면(지정 회사 = 보기만) 업체 고침 · 연도 고르기 · [확정] · [저장] 없음 · [내보내기]만. + * ========================================================================== */ + +import "./D04_PlanConfirm_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { kstText } from "../common_util/common_util_time_kst"; +import { + createButton, + createTag, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { createPanelCard } from "@ui/ui_template_collapsible"; +import { t } from "@ui/ui_template_locale"; +import { stationLabel } from "../common_util/common_util_station"; +import { clearState, readState, writeState } from "../A00_Common/b_page_state"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { searchCompanies } from "../A07_Register/A07_Register_Api_Fetch"; + +interface EndPoint { + x: number; + y: number; + lon: number; + lat: number; +} + +interface YearRow { + year: number; + saved: boolean; + confirmed_at: string | null; + length_m?: number; + start_chainage_m?: number; + end_chainage_m?: number; + start?: EndPoint; + end?: EndPoint; +} + +interface Assignee { + year: number; + company_id: number | null; + company_name: string | null; + email: string | null; + notified_at?: string | null; + linked_project_id?: string | null; +} + +interface Summary { + name: string; + years: YearRow[]; + assignees: Assignee[]; +} + +async function api(path: string, init: RequestInit = {}): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: init.body ? { "Content-Type": "application/json" } : undefined, + ...init, + }); + const data = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = typeof data.detail === "string" ? data.detail : `HTTP ${response.status}`; + throw new Error(detail); + } + return data; +} + +const fmt = (value: number | undefined, digits = 1): string => + value === undefined || value === null ? "—" : value.toFixed(digits); +/** 측점 간격 — D03 `PLAN_STATION_INTERVAL_M` 와 같음 */ +const STATION_INTERVAL_M = 20; +const station = (m: number | undefined): string => + m === undefined || m === null ? "—" : stationLabel(m, STATION_INTERVAL_M, 1); +const where = (p?: EndPoint): string => (p ? `${p.lat.toFixed(6)}, ${p.lon.toFixed(6)}` : "—"); +/** 같은 업체 — 등록 회사 = id · 수기 = 메일(서버 `_key` 와 같음) */ +const sameFirm = (a: Assignee, b: Assignee): boolean => + a.year === b.year && + (a.company_id !== null + ? a.company_id === b.company_id + : b.company_id === null && (a.email ?? "").toLowerCase() === (b.email ?? "").toLowerCase()); + +export async function renderD04PlanConfirm(root: HTMLElement): Promise { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const base = `/projects/${projectId}/plan`; + const main = el("div", { className: "d04-confirm" }); + const pickList = el("div", { className: "d04-confirm__picks" }); + const pickCard = createPanelCard(t("D04_Panel_Years"), false); + pickCard.append(pickList); + const actions = el("div", { className: "ui-sidebar-actions" }); + const panel = el("div", { className: "d04-confirm__panel", children: [pickCard, actions] }); + mountGroupStep(root, ROUTES.D04_PLAN_CONFIRM, { mainContent: main, leftPanel: panel }); + + let summary: Summary = { name: "", years: [], assignees: [] }; + let editable = false; // 산림청만 고침 + const draft = (): Assignee[] | null => + editable ? readState("d04-assignees", projectId) : null; + const current = (): Assignee[] => draft() ?? summary.assignees; + const edit = (next: Assignee[]): void => { + writeState("d04-assignees", next, projectId); + draw(); + }; + + async function load(): Promise { + summary = await api(`${base}/summary`); + draw(); + } + + async function save(): Promise { + const body = { + years: summary.years.map((row) => row.year), + assignees: current().map(({ year, company_id, company_name, email }) => ({ + year, + company_id, + company_name, + email, + })), + }; + await api(`${base}/assignees`, { method: "PUT", body: JSON.stringify(body) }); + clearState("d04-assignees", projectId); + } + + async function locked(work: () => Promise): Promise { + showLoadingOverlay(); + try { + await work(); + await load(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } finally { + hideLoadingOverlay(); + } + } + + function addForm(year: number): HTMLElement { + const listId = `d04-companies-${year}`; + const options = el("datalist", { attrs: { id: listId } }); + const found = new Map(); + const firm = el("input", { + className: "d04-confirm__input", + attrs: { list: listId, placeholder: t("D04_Company") }, + }); + const mail = el("input", { + className: "d04-confirm__input", + attrs: { type: "email", placeholder: t("D04_Email") }, + }); + firm.addEventListener("input", () => { + const query = firm.value.trim(); + if (!query) return; + void searchCompanies(query) + .then((companies) => { + found.clear(); + options.replaceChildren( + ...companies.map((company) => { + found.set(company.name, company.id); + return el("option", { attrs: { value: company.name } }); + }), + ); + }) + .catch(() => undefined); + }); + const add = createButton({ + label: t("D04_Add"), + variant: "ghost", + size: "sm", + onClick: () => { + const name = firm.value.trim(); + const email = mail.value.trim() || null; + const companyId = found.get(name) ?? null; + if (companyId === null && (!name || !email?.includes("@"))) { + showToast(t("D04_NeedEmail"), "warning"); + return; + } + const next: Assignee = { year, company_id: companyId, company_name: name, email }; + const list = current(); + if (list.some((item) => sameFirm(item, next))) return; + edit([...list, next]); + }, + }); + return el("div", { className: "d04-confirm__add", children: [firm, mail, add, options] }); + } + + function firmChip(item: Assignee): HTMLElement { + const label = [item.company_name, item.email].filter(Boolean).join(" · "); + const remove = createButton({ + label: "×", + variant: "ghost", + size: "sm", + onClick: () => edit(current().filter((other) => other !== item && !sameFirm(other, item))), + }); + const tags = [ + item.company_id !== null ? createTag("Aislo", "accent") : null, + item.notified_at ? createTag(t("D04_Sent"), "success") : null, + item.linked_project_id ? createTag(t("D04_Linked"), "success") : null, + ].filter((tag): tag is HTMLSpanElement => tag !== null); + remove.hidden = !editable; + return el("div", { + className: "d04-confirm__firm", + children: [el("span", { text: label }), ...tags, remove], + }); + } + + function draw(): void { + const list = current(); + const head = el("tr", { + children: [ + "D04_Col_Year", + "D04_Col_Length", + "D04_Col_Start", + "D04_Col_End", + "D04_Col_Chainage", + "D04_Col_Confirmed", + "D04_Col_Assignees", + ].map((key) => el("th", { text: t(key as Parameters[0]) })), + }); + const rows = summary.years.map((row) => { + const firms = list.filter((item) => item.year === row.year).map(firmChip); + const chain = row.saved + ? `${station(row.start_chainage_m)} ~ ${station(row.end_chainage_m)}` + : t("D04_NotSaved"); + return el("tr", { + attrs: { "data-year": String(row.year) }, + children: [ + el("td", { text: String(row.year) }), + el("td", { text: fmt(row.length_m), attrs: { "data-num": "" } }), + el("td", { text: where(row.start) }), + el("td", { text: where(row.end) }), + el("td", { text: chain }), + el("td", { text: kstText(row.confirmed_at) }), + el("td", { children: editable ? [...firms, addForm(row.year)] : firms }), + ], + }); + }); + const table = el("table", { + className: "d04-confirm__table", + children: [el("thead", { children: [head] }), el("tbody", { children: rows })], + }); + const notes = draft() + ? [el("p", { className: "d04-confirm__dirty", text: t("D04_Dirty") })] + : []; + main.replaceChildren(el("h2", { text: summary.name }), ...notes, table); + + pickList.replaceChildren( + ...summary.years + .filter((row) => row.saved) + .map((row) => { + const box = el("input", { attrs: { type: "checkbox", value: String(row.year) } }); + box.checked = !row.confirmed_at; + return el("label", { className: "d04-confirm__pick", children: [box, ` ${row.year}`] }); + }), + ); + } + + const exportButton = createButton({ + label: t("D04_Export"), + variant: "ghost", + onClick: () => + void (async () => { + showLoadingOverlay(); + try { + const response = await fetch(`${API_BASE_URL}${base}/export`, { + credentials: "include", + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const link = el("a", { + attrs: { + href: URL.createObjectURL(await response.blob()), + download: `${summary.name}_예정노선.zip`, + }, + }); + link.click(); + URL.revokeObjectURL(link.href); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } finally { + hideLoadingOverlay(); + } + })(), + }); + const finalizeButton = createButton({ + label: t("D04_Finalize"), + variant: "filled", + onClick: () => + void (async () => { + const years = [...pickList.querySelectorAll("input:checked")].map((box) => + Number(box.value), + ); + if (!years.length) { + showToast(t("D04_FinalizePick"), "warning"); + return; + } + if (!(await showConfirmDialog(t("D04_FinalizeAsk"), t("D04_Finalize")))) return; + await locked(async () => { + if (draft()) await save(); + const result = await api<{ notifications: { state: string }[] }>(`${base}/finalize`, { + method: "POST", + body: JSON.stringify({ years }), + }); + const counts = new Map(); + for (const item of result.notifications) { + counts.set(item.state, (counts.get(item.state) ?? 0) + 1); + } + const text = [...counts].map(([state, n]) => `${state} ${n}`).join(" · "); + showToast(`${t("D04_Finalized")} ${text || "0"}`, "success"); + }); + })(), + }); + const saveButton = createButton({ + label: t("D04_Save"), + variant: "filled", + onClick: () => + void locked(async () => { + await save(); + showToast(t("D04_Saved"), "success"); + }), + }); + actions.append(exportButton, finalizeButton, saveButton); + + try { + editable = !!(await fetchSessionUser())?.is_forest_service; + pickCard.hidden = finalizeButton.hidden = saveButton.hidden = !editable; + await load(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } +} diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css b/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css new file mode 100644 index 000000000..05556bae3 --- /dev/null +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css @@ -0,0 +1,62 @@ +/* ============================================================================= + * D04_PlanConfirm_UI_Style.css — D04 확정 · 업체 표(531-3) · 색은 테마 토큰만 + * ========================================================================== */ + +.d04-confirm { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + padding: var(--spacing-16); + overflow: auto; +} + +.d04-confirm__table { + border-collapse: collapse; + font-size: var(--text-body-sm); + color: var(--color-text-body); +} + +.d04-confirm__table th, +.d04-confirm__table td { + padding: var(--spacing-8); + border: 1px solid var(--color-border); + text-align: left; + vertical-align: top; +} + +.d04-confirm__table thead th { + background: var(--color-surface); +} + +.d04-confirm__table td[data-num] { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.d04-confirm__firm, +.d04-confirm__add { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + margin-bottom: var(--spacing-4); +} + +.d04-confirm__input { + min-width: 10rem; + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); +} + +.d04-confirm__dirty { + color: var(--color-warning, var(--color-text-secondary)); +} + +.d04-confirm__picks { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} diff --git a/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts b/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts new file mode 100644 index 000000000..9eefce73a --- /dev/null +++ b/D04_PlanConfirm/draft/D04_PlanConfirm_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * D04_PlanConfirm_Route.ts + * 길 선언(PLAN 529-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = D04 확정 화면(531-3) + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.D04_PLAN_CONFIRM, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../D04_PlanConfirm_UI_Page")).renderD04PlanConfirm, +}; diff --git a/E01_StabRegister/E01_StabRegister_Router.py b/E01_StabRegister/E01_StabRegister_Router.py new file mode 100644 index 000000000..c356b8d75 --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_Router.py @@ -0,0 +1,130 @@ +"""E01 안정성평가 생성 API (요구 345 · 347 · 요구서 4-1). + +대상 = 같은 회사 임도(ROAD) 중 B06 횡단 단계(stage 3) 완료. 생성 때 해석에 쓰는 자료만 +`E01_StabRegister/source/` 로 동결 복사(532-1 · 원본이 바뀌어도 불변) +— 실패하면 만든 행 · 폴더를 지움. +""" + +import logging +from datetime import datetime +from pathlib import Path +from typing import Any + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from common_util.common_util_auth import require_company +from common_util.common_util_project_copy import copy_project +from common_util.common_util_project_delete import hard_delete_project +from common_util.common_util_project_kind import create_kind_project +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool +from E02_StabGround.E02_StabGround_Router import seed_initial + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/e01", tags=["E01_StabRegister"]) + +_SOURCE_SQL = """ + SELECT p.id AS project_id, p.name, p.region, p.road_type, p.project_year, + DATE_FORMAT(p.updated_at, '%%Y-%%m-%%dT%%H:%%i:%%s') AS updated_at, + s.state = 'COMPLETE' AS section_complete + FROM projects p + LEFT JOIN project_workflow_stages s ON s.project_id = p.id AND s.stage_no = 3 + WHERE p.company_id = %s AND p.project_kind = 'ROAD' AND p.deleted_at IS NULL +""" + + +class CreateStabRequest(BaseModel): + project_name: str | None = Field(default=None, max_length=255) + source_project_id: str = Field(..., min_length=36, max_length=36) + + +async def _sources(company_id: Any, project_id: str | None = None) -> list[dict[str, Any]]: + sql, args = _SOURCE_SQL, [company_id] + if project_id: + sql, args = sql + " AND p.id = %s", [company_id, project_id] + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute(sql + " ORDER BY p.updated_at DESC", tuple(args)) + return list(await cursor.fetchall()) + + +@router.get("/source-candidates") +async def source_candidates( + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """B06 까지 끝난 같은 회사 임도 프로젝트.""" + rows = await _sources(session.get("company_id")) + projects = [ + {k: v for k, v in r.items() if k != "section_complete"} + for r in rows + if r["section_complete"] + ] + return {"status": "success", "projects": projects} + + +@router.post("/project") +async def post_stab_project( + request: Request, + payload: CreateStabRequest, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """안정성평가 프로젝트 생성 — 원본 B06 미완료면 400 · 횡단 없는 측점은 warnings.""" + company_id = session.get("company_id") + rows = await _sources(company_id, payload.source_project_id) if company_id else [] + if not rows: + raise HTTPException(status_code=404, detail="같은 회사 임도 프로젝트가 아닙니다.") + source = rows[0] + if not source["section_complete"]: + raise HTTPException( + status_code=400, detail="원본 임도의 횡단 설계(B06)가 끝나지 않았습니다." + ) + name = (payload.project_name or "").strip() or f"{source['name']} 안정성평가" + project_id = await create_kind_project( + request=request, + user_id=int(session["user_id"]), + company_id=int(company_id), + kind="STABILITY", + name=name, + region=source["region"], + road_type=source["road_type"], + project_year=source["project_year"], + source_project_id=source["project_id"], + ) + try: + manifest = await _copy_source(project_id, source["project_id"]) + await seed_initial(project_id) # E02 초기값(기본값 + 복사본 B06 측점) + except Exception: + logger.exception("E01 복사 실패 — 만든 프로젝트를 지움: %s", project_id) + await hard_delete_project(project_id, int(session["user_id"]), request) + raise HTTPException(status_code=500, detail="원본 자료 복사에 실패했습니다.") from None + return { + "project_id": project_id, + "source_fingerprint": manifest["source_fingerprint"], + "warnings": manifest.get("warnings", []), + } + + +async def _copy_source(project_id: str, source_id: str) -> dict[str, Any]: + """원본 → E 복사본 + 지문 · 복사 시각 기록.""" + async with get_db_pool().acquire() as connection: + async with connection.cursor() as cursor: + await cursor.execute("SELECT storage_path FROM projects WHERE id = %s", (project_id,)) + (storage_path,) = await cursor.fetchone() + manifest = await copy_project( + connection, + source_id, + scope="stability", + new_id=project_id, + project_root=Path(resolve_stored_project_path(storage_path)), + ) + async with connection.cursor() as cursor: + await cursor.execute( + "UPDATE projects SET source_fingerprint = %s, source_copied_at = %s WHERE id = %s", + (manifest["source_fingerprint"], datetime.utcnow(), project_id), + ) + return manifest diff --git a/E01_StabRegister/E01_StabRegister_Source.py b/E01_StabRegister/E01_StabRegister_Source.py new file mode 100644 index 000000000..70bb98bb7 --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_Source.py @@ -0,0 +1,156 @@ +"""E 안정성평가 복사본 읽기 — storage 를 아는 유일한 자리 (요구 345 · 532-1). + +복사본 = E 프로젝트 저장소 `E01_StabRegister/source/`(`common_util_project_copy.copy_stability` +이 생성 때 한 번 만듦 · 그 뒤 불변). E 엔진 · 화면은 이 함수들로만 받음 — 원본 임도 프로젝트를 +읽지 않음. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +import aiomysql + +from common_util.common_util_project_copy import ( + MANIFEST_NAME, + STABILITY_DB_NAME, + STABILITY_SOURCE_DIR, + resolve_cross_file, +) +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool + + +def source_dir(project_root: Path | str) -> Path: + """E 프로젝트 루트 → 복사본 폴더.""" + return Path(project_root) / STABILITY_SOURCE_DIR + + +async def project_source_dir(project_id: str) -> Path: + """E project_id → 복사본 폴더(없으면 LookupError).""" + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT storage_path FROM projects" + " WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL", + (project_id,), + ) + row = await cursor.fetchone() + if not row: + raise LookupError(f"안정성평가 프로젝트가 아님: {project_id}") + root = source_dir(resolve_stored_project_path(row["storage_path"])) + if not (root / MANIFEST_NAME).is_file(): + raise LookupError(f"복사본이 없음: {project_id}") + return root + + +@lru_cache(maxsize=8) +def _read_json(path: str, mtime_ns: int) -> Any: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def _json(path: Path) -> Any: + return _read_json(str(path), path.stat().st_mtime_ns) + + +def manifest(root: Path) -> dict[str, Any]: + """복사 목록 — 원본 id · 복사 시각 · 지문 · 파일.""" + return _json(root / MANIFEST_NAME) + + +def db_snapshot(root: Path) -> dict[str, Any]: + """동결 DB 행 — project · project_workflow_stages · surface_params · route · + route_points · route_statistics · longitudinal_sections · cross_sections.""" + return _json(root / STABILITY_DB_NAME) + + +def _cross_rows(root: Path) -> list[dict[str, Any]]: + rows = [row for row in db_snapshot(root)["cross_sections"] if row["chainage_m"] is not None] + return sorted(rows, key=lambda row: (float(row["chainage_m"]), row.get("sequence_num") or 0)) + + +def _row_data(row: dict[str, Any]) -> dict[str, Any]: + data = row.get("data") + return (json.loads(data) if isinstance(data, str) else data) or {} + + +def stations(root: Path) -> list[dict[str, Any]]: + """측점 목록(누가 차례) — chainage_m · label · 중심 좌표 · 방위각 · 오르막 쪽 · 지반 유형. + + 횡단 파일을 못 찾은 측점(`missing_stations`)은 「횡단 없음」으로 빠짐 — 전체는 안 깨짐.""" + out = [] + for row in _cross_rows(root): + path = resolve_cross_file(root, row) + if path is None: + continue + cross = _json(path) + out.append( + { + "chainage_m": float(row["chainage_m"]), + "label": cross.get("label"), + "kind": cross.get("kind"), + "center_x": cross.get("center_x"), + "center_y": cross.get("center_y"), + "center_z": cross.get("center_z"), + "azimuth_deg": cross.get("azimuth_deg"), + "uphill_side": cross.get("uphill_side"), + "ground_type": (_row_data(row).get("design") or {}).get("ground_type"), + } + ) + return out + + +def missing_stations(root: Path) -> list[float]: + """횡단 없음으로 빠진 측점 누가거리 — 화면 알림용(개수 = len).""" + return [ + float(row["chainage_m"]) + for row in _cross_rows(root) + if resolve_cross_file(root, row) is None + ] + + +def cross_section(root: Path, chainage_m: float) -> dict[str, Any]: + """한 측점 횡단 — 지반선(samples: offset_m · x · y · z · valid) · 좌표틀 · B06 설계값 + (`design`: design_line · road_edges · section_mode · 절성토 경사 · 소단 · ground_type · + rock_boundary_offset_m(암 경계 = 지반선을 그만큼 내린 선 · 암 측점만) …). + 측점이 없거나 횡단 없음이면 KeyError.""" + for row in _cross_rows(root): + if abs(float(row["chainage_m"]) - float(chainage_m)) < 1e-6: + path = resolve_cross_file(root, row) + if path is None: + raise KeyError(f"횡단 없음: {chainage_m}") + cross = _json(path) + return { + "chainage_m": float(row["chainage_m"]), + "label": cross.get("label"), + "frame": cross.get("frame"), + "uphill_side": cross.get("uphill_side"), + "samples": cross.get("samples") or [], + "design": _row_data(row).get("design") or {}, + } + raise KeyError(f"측점 없음: {chainage_m}") + + +def terrain_grid_path(root: Path) -> Path: + """복사한 지형 격자(DTM npz — 원본 설계가 쓴 그 하나).""" + found = sorted((root / "B04_PreProcess" / "models").glob("dtm_*.npz")) + if len(found) != 1: + raise FileNotFoundError(f"복사한 지형 격자가 하나가 아님: {[p.name for p in found]}") + return found[0] + + +def corridor_path(root: Path) -> Path | None: + """최신 노선 3D 노선면(없으면 None).""" + found = sorted((root / "B05_Profile" / "corridor").glob("corridor_*.json")) + return found[0] if found else None + + +def structures_path(root: Path) -> Path | None: + """노선 구조물 정본(벽 등 · 없으면 None).""" + path = root / "B05_Profile" / "route" / "structures.json" + return path if path.is_file() else None diff --git a/E01_StabRegister/E01_StabRegister_UI_Missing.css b/E01_StabRegister/E01_StabRegister_UI_Missing.css new file mode 100644 index 000000000..0e54a15cf --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_UI_Missing.css @@ -0,0 +1,21 @@ +/* 빠진 횡단 띠 — E02 · E03 화면 위(E01_StabRegister_UI_Missing.ts) */ +.e-missing { + margin: 0 0 8px; + padding: 6px 10px; + border: 1px solid var(--color-warning); + border-left-width: 4px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + color: var(--color-text); + font-size: 13px; +} + +.e-missing > summary { + cursor: pointer; + font-weight: 600; +} + +.e-missing > p { + margin: 6px 0 0; + color: var(--color-text-secondary); +} diff --git a/E01_StabRegister/E01_StabRegister_UI_Missing.ts b/E01_StabRegister/E01_StabRegister_UI_Missing.ts new file mode 100644 index 000000000..252d0ccc9 --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_UI_Missing.ts @@ -0,0 +1,24 @@ +/* ============================================================================= + * E01_StabRegister_UI_Missing.ts + * 빠진 횡단 띠(요구 351 · PLAN 532-5) — E02 · E03 화면 위 「횡단 없는 측점 N 개 — 해석에서 빠짐」. + * 목록 = 서버 `missing_stations`(E01 복사본에 횡단 파일이 없는 측점 누가거리) · 누르면 펼침. + * ========================================================================== */ + +import "./E01_StabRegister_UI_Missing.css"; +import { el } from "@ui/ui_template_elements"; +import { t } from "@ui/ui_template_locale"; + +/** 빠진 측점이 없으면 빈 칸(띠 없음) */ +export function buildMissingBanner(missing: number[] | undefined): HTMLElement { + const list = missing ?? []; + if (!list.length) return el("div"); + const box = el("details", { + className: "e-missing", + attrs: { "data-role": "missing-stations", "data-count": String(list.length) }, + }); + box.append( + el("summary", { text: t("E_Missing_Head").replace("{n}", String(list.length)) }), + el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }), + ); + return box; +} diff --git a/E01_StabRegister/E01_StabRegister_UI_Page.ts b/E01_StabRegister/E01_StabRegister_UI_Page.ts new file mode 100644 index 000000000..461f78469 --- /dev/null +++ b/E01_StabRegister/E01_StabRegister_UI_Page.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * E01_StabRegister_UI_Page.ts + * E01 안정성평가 생성(요구서 345 4-1 · PLAN 528-2) — 대상 임도 고르기(GET /api/e01/source-candidates + * = 같은 회사 · B06 완료만) · 이름(손대기 전 = 「원본 이름 안정성평가」). + * [등록] = POST /api/e01/project → 현재 프로젝트 · E02 로. + * ========================================================================== */ + +import "../B02_ProjRegister/B02_ProjRegister_UI_Style.css"; +import { API_BASE_URL, ROUTES } from "@config/config_frontend"; +import { + createButton, + createCard, + createInputField, + createSelectField, + el, +} from "@ui/ui_template_elements"; +import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { createHelpButton } from "@ui/ui_template_help"; +import { t } from "@ui/ui_template_locale"; +import { createGroupProject } from "../A00_Common/group_workflow"; +import { navigateTo } from "../A00_Common/router"; + +interface SourceProject { + project_id: string; + name: string; + region?: string | null; + project_year?: number | null; +} + +export function renderE01StabRegister(root: HTMLElement): void { + const source = createSelectField({ label: t("DE_Reg_Source"), options: [] }); + const name = createInputField({ label: t("DE_Reg_Name") }); + const note = el("p", { className: "ui-general-block__empty" }); + let sources: SourceProject[] = []; + let typed = false; + const autoName = (): string => { + const picked = sources.find((p) => p.project_id === source.select.value); + return picked ? `${picked.name} ${t("DE_New_Stability")}` : ""; + }; + name.input.addEventListener("input", () => (typed = name.input.value.trim() !== "")); + source.select.addEventListener("change", () => { + if (!typed) name.input.value = autoName(); + }); + + const submitButton = createButton({ + label: t("DE_Reg_Submit"), + variant: "filled", + onClick: () => { + // 목록이 비면 단추가 꺼져 있음 — 고른 값은 늘 있음 + void createGroupProject( + "/e01/project", + { project_name: name.input.value.trim() || null, source_project_id: source.select.value }, + ROUTES.E02_STAB_GROUND, + ); + }, + }); + submitButton.disabled = true; + + void (async () => { + const response = await fetch(`${API_BASE_URL}/e01/source-candidates`, { + credentials: "include", + }); + sources = response.ok + ? ((await response.json()) as { projects: SourceProject[] }).projects + : []; + source.setOptions( + sources.map((p) => ({ + value: p.project_id, + text: [p.name, p.region, p.project_year].filter(Boolean).join(" · "), + })), + ); + note.textContent = sources.length ? "" : t("DE_Reg_SourceEmpty"); + submitButton.disabled = !sources.length; + if (!typed) name.input.value = autoName(); + })(); + + const grid = document.createElement("div"); + grid.className = "b02-proj__grid"; + grid.append(source.root, name.root); + const actions = document.createElement("div"); + actions.className = "ui-general-block__actions b02-proj__actions"; + actions.append( + createHelpButton(), + createButton({ + label: t("Common_Btn_Cancel"), + variant: "ghost", + onClick: () => navigateTo(ROUTES.B01_ACCOUNT), + }), + submitButton, + ); + const layout = createGeneralLayout({ + pageClass: "b02-proj", + title: t("DE_Reg_StabTitle"), + subtitle: t("DE_Reg_StabSubtitle"), + content: createCard({ body: [grid, note, actions], raised: true }), + }); + root.append(layout.root); +} diff --git a/E01_StabRegister/draft/E01_StabRegister_Route.ts b/E01_StabRegister/draft/E01_StabRegister_Route.ts new file mode 100644 index 000000000..e0f866b09 --- /dev/null +++ b/E01_StabRegister/draft/E01_StabRegister_Route.ts @@ -0,0 +1,15 @@ +/* ============================================================================= + * E01_StabRegister_Route.ts + * 등록 화면 길 선언(PLAN 529-1 · 528-2) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 필요(B02 와 같음) · 꼬리말 없음 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E01_STAB_REGISTER, + workflow: true, + footerless: true, + load: async () => (await import("../E01_StabRegister_UI_Page")).renderE01StabRegister, +}; diff --git a/E02_StabGround/E02_StabGround_Model.py b/E02_StabGround/E02_StabGround_Model.py new file mode 100644 index 000000000..c30ad2fcc --- /dev/null +++ b/E02_StabGround/E02_StabGround_Model.py @@ -0,0 +1,361 @@ +"""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`. +지하수 → 엔진 꼴(`station_groundwater` · 서버 단독 · 짝 없음) — +시험 `test_532_4_stab_walls_water.py`. +""" + +from __future__ import annotations + +import copy +import json +import math +from pathlib import Path +from typing import Any + +import numpy as np + +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") +# 우기 지하수 갈래 — kds = 위 규칙(wet_soil · wet_rock · 기본) · +# depth_ratio = 지표 − 비탈 높이 × 비율 · ru = 간극수압비 · +# line = 사용자 수위선(구간 · [offset, 지표 아래 깊이]) · 덮지 않은 측점은 kds +WET_MODES = ("kds", "depth_ratio", "ru", "line") +WATER_EXTRA_KEYS = ("wet_mode", "depth_ratio", "ru", "lines") +# 정본에 남기는 칸 — 그 밖(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 upgrade(ground: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]: + """옛 정본(지하수 갈래 칸 전) = 빠진 지하수 칸만 기본값으로 채운 사본 · 있는 칸은 그대로.""" + out = copy.deepcopy(ground) + water = out.setdefault("groundwater", {}) + if isinstance(water, dict): + for key in WATER_EXTRA_KEYS: + water.setdefault(key, copy.deepcopy(defaults["groundwater"][key])) + return out + + +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 · few_points · 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") + if water.get("wet_mode") not in WET_MODES: + bad("groundwater.wet_mode", "enum") + ranged("groundwater.depth_ratio", water.get("depth_ratio"), "depth_ratio") + ranged("groundwater.ru", water.get("ru"), "ru") + if not isinstance(water.get("lines"), list): + bad("groundwater.lines", "type") + for i, line in enumerate(_list(water.get("lines"))): + base = f"groundwater.lines.{i}" + if not isinstance(line, dict): + bad(base, "type") + continue + ranged(f"{base}.from_m", line.get("from_m"), "chainage_m") + ranged(f"{base}.to_m", line.get("to_m"), "chainage_m") + if _num(line.get("from_m")) and _num(line.get("to_m")): + if line["from_m"] >= line["to_m"]: + bad(f"{base}.to_m", "order") + points = _list(line.get("points")) + if len(points) < 2: + bad(f"{base}.points", "few_points") + last = None + for k, point in enumerate(points): + if not isinstance(point, list) or len(point) != 2: + bad(f"{base}.points.{k}", "type") + continue + ranged(f"{base}.points.{k}.0", point[0], "offset_m") + ranged(f"{base}.points.{k}.1", point[1], "water_depth_m") + if _num(point[0]): + if last is not None and point[0] <= last: + bad(f"{base}.points.{k}.0", "order") + last = point[0] + 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} + water = out["groundwater"] + out["groundwater"] = {key: water[key] for key in (*WATER_KEYS, *WATER_EXTRA_KEYS)} + out["groundwater"]["lines"] = [ + {"from_m": w["from_m"], "to_m": w["to_m"], "points": [list(p) for p in w["points"]]} + for w in water["lines"] + ] + 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 + + +def _face_water(ground: dict[str, Any], chainage_m: float) -> str: + """측점 비탈 겉 지층의 지하수 무리 — 토층 두께 0 = 아래(암) 지층 · 아니면 위 지층 · + 측점 없음 = soil.""" + names = {m["id"]: m for m in ground.get("materials") or []} + for row in resolve_stations(ground): + if abs(row["chainage_m"] - chainage_m) < 1e-3: + face = row["lower"] if row["lower"] and row["soil_thickness_m"] == 0 else row["upper"] + return (names.get(face) or {}).get("water", "soil") + return "soil" + + +def groundwater_for( + ground: dict[str, Any], + condition: str, + *, + chainage_m: float, + surface: list, + heights: dict[str, float], +) -> dict[str, Any]: + """E02 지하수 규칙 → 엔진 꼴 `{mode: none | ru | line}`(E03 Types `Groundwater`) — 측점마다. + + surface = 공사 뒤 지표 [[x, z]](좌 = +x) · heights = 비탈 높이 {left, right} m(x ≥ 0 = 좌 높이). + 건기 · 지진 · 단기 = `dry` 규칙 · 우기 = `wet_mode` 갈래 + (kds = 겉 지층 무리로 wet_soil | wet_rock). + """ + water = ground.get("groundwater") or {} + sx, sz = zip(*sorted((float(x), float(z)) for x, z in surface)) + + def below(depth_at, extra=()) -> dict[str, Any]: + # 좌 · 우 높이가 달라 x = 0 에서 끊길 수 있음 — 0 양쪽 점을 넣어 계단으로 + xs = sorted({*sx, *extra, -1e-6, 1e-6}) + zs = np.interp(xs, sx, sz) - np.array([depth_at(x) for x in xs]) + return {"mode": "line", "line": [[float(x), float(z)] for x, z in zip(xs, zs)]} + + def height(x: float) -> float: + return float(heights.get("left" if x >= 0 else "right") or 0.0) + + def rule(name: str) -> dict[str, Any]: + if name == "surface": + return below(lambda x: 0.0) + if name == "half_height": + return below(lambda x: 0.5 * height(x)) + return {"mode": "none"} + + if condition != "wet": + return rule(water.get("dry", "none")) + mode = water.get("wet_mode", "kds") + if mode == "ru": + return {"mode": "ru", "ru": float(water["ru"])} + if mode == "depth_ratio": + ratio = float(water["depth_ratio"]) + return below(lambda x: ratio * height(x)) + if mode == "line": + hit = None + for item in water.get("lines") or []: # 뒤 구간이 이김(지층 구간과 같은 규칙) + if item["from_m"] <= chainage_m <= item["to_m"]: + hit = item + if hit is not None: + ox, depth = zip(*sorted((float(o), float(d)) for o, d in hit["points"])) + return below(lambda x: float(np.interp(x, ox, depth)), ox) + group = _face_water(ground, chainage_m) + return rule(water.get("wet_rock" if group == "rock" else "wet_soil", "none")) + + +def station_groundwater(ground: dict[str, Any], condition: str, model: dict[str, Any]) -> dict: + """E03 측점 모형(`section_model_from_b06` 결과) → 그 측점 지하수 — 부르는 쪽이 + `model["groundwater"] = station_groundwater(ground, cond, model)` 로 갈아 끼움.""" + heights = {side["side"]: side["height_m"] for side in model.get("sides") or []} + return groundwater_for( + ground, + condition, + chainage_m=float((model.get("station") or {}).get("chainage_m") or 0.0), + surface=model["surface"], + heights=heights, + ) diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py new file mode 100644 index 000000000..aee593430 --- /dev/null +++ b/E02_StabGround/E02_StabGround_Router.py @@ -0,0 +1,154 @@ +"""E02 지반 조건 API (요구 347-2 · 3 · 4 · PLAN 532-3). + +- `GET /api/projects/{id}/stab/ground` — 작업본(없으면 초기값) + 기본값 + 고칠 수 있는지 + + 횡단 없는 측점(`missing_stations` · 화면 띠) +- `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json` + · 측점(B06 암 경계)은 서버 값 +- `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) + +초기값 = E 생성 직후 마스터 기본값 + 복사본 B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음. +공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path +from common_util.common_util_auth import verify_session +from common_util.common_util_json import atomic_write_json +from common_util.common_util_project_copy import MANIFEST_NAME +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool +from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M +from E01_StabRegister.E01_StabRegister_Source import db_snapshot, missing_stations, source_dir +from E02_StabGround import E02_StabGround_Model as model + +router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"]) + + +class GroundRequest(BaseModel): + ground: dict[str, Any] + + +def _b06_stations(project_root: Path) -> list[dict]: + """측점 · 지반 라벨 · 토층 두께(암 측점 = 암 경계 깊이 · 토사 = 없음). + + E01 동결 복사본(`E01_StabRegister/source/db.json`)만 읽음 — 복사본이 없으면 빈 목록. + """ + root = source_dir(project_root) + if not (root / MANIFEST_NAME).is_file(): + return [] + rows = sorted(db_snapshot(root)["cross_sections"], key=lambda r: float(r["chainage_m"] or 0)) + stations = [] + for row in rows: + data = json.loads(row["data"]) if isinstance(row["data"], str) else row["data"] or {} + design = data.get("design") or {} + ground_type = design.get("ground_type") or "soil" + offset = design.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M) + rock = ground_type in model.ROCK_LABELS and offset is not None + stations.append( + { + "chainage_m": round(float(row["chainage_m"]), 3), + "ground_type": ground_type, + "soil_thickness_m": round(abs(float(offset)), 3) if rock else None, + } + ) + return stations + + +async def _project(project_id: UUID) -> tuple[Path, dict[str, Any]]: + async with get_db_pool().acquire() as connection: + try: + stored = await get_project_storage_relative_path(connection, project_id) + except Exception as error: + raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.") from error + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT company_id, project_kind FROM projects WHERE id = %s", + (str(project_id),), + ) + project = await cursor.fetchone() + if not project or project["project_kind"] != "STABILITY": + raise HTTPException(status_code=400, detail="안정성평가 프로젝트가 아닙니다.") + return Path(resolve_stored_project_path(stored)), project + + +def ensure_initial(project_root: Path) -> dict: + """초기값이 없을 때만 한 번 — 기본값 + 복사본 B06 측점(E 생성 직후 · 옛 E 프로젝트는 첫 GET).""" + path = model.initial_path(project_root) + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + ground = model.build_initial(model.load_defaults(), _b06_stations(project_root)) + atomic_write_json(path, ground) + return ground + + +async def seed_initial(project_id: str) -> None: + """E01 생성 직후 부름 — 초기값 한 벌.""" + root, _ = await _project(UUID(project_id)) + ensure_initial(root) + + +def _missing(project_root: Path) -> list[float]: + """횡단 없어 해석에서 빠진 측점 — 복사본이 없으면 빈 목록.""" + root = source_dir(project_root) + return missing_stations(root) if (root / MANIFEST_NAME).is_file() else [] + + +def _read_working(project_root: Path) -> dict[str, Any] | None: + path = model.ground_path(project_root) + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def _editable(session: dict[str, Any], project: dict[str, Any]) -> bool: + return session["role"] == "SYSTEM_ADMIN" or session.get("company_id") == project["company_id"] + + +@router.get("/{project_id}/stab/ground") +async def get_ground( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root, project = await _project(project_id) + initial = ensure_initial(root) + working = _read_working(root) + defaults = model.load_defaults() + return { + # 옛 정본(지하수 갈래 칸 전) = 빠진 칸만 기본값으로 채워 보냄(파일은 [저장] 때 씀) + "ground": model.upgrade(working or initial, defaults), + "saved": working is not None, + "defaults": defaults, + "editable": _editable(session, project), + "missing_stations": _missing(root), + } + + +@router.put("/{project_id}/stab/ground") +async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: + root, _ = await _project(project_id) + defaults = model.load_defaults() + errors = model.check_ground(payload.ground, defaults["limits"]) + if errors: + raise HTTPException(status_code=422, detail={"errors": errors}) + ground = model.clean(payload.ground) + # 측점(B06 암 경계)은 서버 값 — 브라우저 값을 받아 적지 않음 + base = _read_working(root) or ensure_initial(root) + ground["stations"] = base.get("stations", []) + ground["version"] = defaults["version"] + atomic_write_json(model.ground_path(root), ground) + return {"ground": ground} + + +@router.post("/{project_id}/stab/ground/reset") +async def reset_ground(project_id: UUID) -> dict[str, Any]: + root, _ = await _project(project_id) + initial = ensure_initial(root) + atomic_write_json(model.ground_path(root), initial) + return {"ground": model.upgrade(initial, model.load_defaults())} diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts new file mode 100644 index 000000000..b45bb974f --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -0,0 +1,252 @@ +/* ============================================================================= + * 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 { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; +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; + missing_stations?: number[]; +} + +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"; + if (/\.points\.\d+\.0$/.test(path)) return "offset_m"; + if (/\.points\.\d+\.1$/.test(path)) return "water_depth_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; + let missing: number[] = []; + + 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(buildMissingBanner(missing), 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)); + // 옛 캐시(지하수 갈래 칸 전) — 빠진 칸은 서버 값(서버가 기본값으로 채워 보냄) + ground.groundwater = { ...body.ground.groundwater, ...ground.groundwater }; + 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`); + missing = body.missing_stations ?? []; + 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..88b33d304 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Style.css @@ -0,0 +1,186 @@ +/* ============================================================================= + * 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 { + min-width: 56px; + 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); +} + +.e02-table .ui-btn { + white-space: nowrap; +} + +.e02-input[data-path$=".water"] { + min-width: 120px; +} + +/* 좁은 화면 — 표만 옆으로 밀림(카드 밖으로 안 나감) */ +.e02-card { + overflow-x: auto; +} + +/* 물성 표는 열이 많아 한 줄 통째 */ +.e02-card--wide { + grid-column: 1 / -1; +} + +/* 지하수 카드 — 사용자 수위선 점(offset · 깊이) 줄 */ +.e02-points { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4); + align-items: center; +} + +.e02-point { + display: inline-flex; + gap: 2px; + align-items: center; +} diff --git a/E02_StabGround/E02_StabGround_UI_Tables.ts b/E02_StabGround/E02_StabGround_UI_Tables.ts new file mode 100644 index 000000000..5b38bd0b1 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Tables.ts @@ -0,0 +1,484 @@ +/* ============================================================================= + * E02_StabGround_UI_Tables.ts + * E02 지반 조건 본문 카드 넷(요구 347-2 · 3 · 4) — 물성 표(지층 추가 · 지움 · B06 지반 연결) · + * 층 두께 · 구간 덮어쓰기 + 측점별 적용 · 기준안전율 · 하중 · 지진 · 지하수(`_UI_Water`). 카드마다 [기본값]. + * 칸 = 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, + resolveStations, + type Ground, + type GroundDefaults, + type Material, +} from "./logic/E02_StabGround_Check"; +import { waterCard } from "./E02_StabGround_UI_Water"; + +export interface E02View { + ground: Ground; + defaults: GroundDefaults; + editable: boolean; + change: () => void; + rebuild: () => void; +} + +const L = (key: string): string => t(key as LocaleKey); +export 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) · 아니면 숫자 아님으로 검사에 걸림 */ +export 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; +} + +export 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; +} + +export 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 }; +} + +export 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); +} + +export 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); + section.classList.add("e02-card--wide"); + 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. 노면 하중 · 지진 (지하수 = `E02_StabGround_UI_Water`) --- */ +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 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, + }, + ), + ), + 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.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); + zones.section.classList.add("e02-card--wide"); + root.append( + materialsCard(view), + safetyCard(view), + loadsCard(view), + waterCard(view), + zones.section, + ); + return { root, refreshStations: zones.refresh }; +} diff --git a/E02_StabGround/E02_StabGround_UI_Water.ts b/E02_StabGround/E02_StabGround_UI_Water.ts new file mode 100644 index 000000000..e613ae911 --- /dev/null +++ b/E02_StabGround/E02_StabGround_UI_Water.ts @@ -0,0 +1,182 @@ +/* ============================================================================= + * E02_StabGround_UI_Water.ts + * E02 지하수 카드(요구 347 · 351 · PLAN 532-3) — 건기 규칙 · 우기 갈래 넷(KDS 기본 · 지표 아래 깊이 비율 · + * r_u · 사용자 수위선 구간) · 「KDS 기본 · 우기 기준안전율 1.2 와 짝」 안내 · [기본값]. + * 엔진 꼴로 바꾸기는 서버(`E02_StabGround_Model.station_groundwater`) — 여기는 값만 고침. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { WATER_KEYS, WATER_RULES, WET_MODES, type WetMode } from "./logic/E02_StabGround_Check"; +import { + card, + clone, + headButtons, + numInput, + selectInput, + table, + type E02View, +} from "./E02_StabGround_UI_Tables"; + +const L = (key: string): string => t(key as LocaleKey); + +function field(label: string, input: HTMLElement): HTMLElement { + const node = el("label", { className: "e02-field" }); + node.append(el("span", { text: label }), input); + return node; +} + +function removeButton(view: E02View, onClick: () => void): HTMLButtonElement { + const button = createButton({ + label: t("E02_Btn_Remove"), + variant: "danger-ghost", + size: "sm", + onClick, + }); + button.hidden = !view.editable; + return button; +} + +/** 사용자 수위선 구간 표 — 줄마다 시작 · 끝 · 점(offset · 깊이) · [점 추가] · [지움] */ +function linesBody(view: E02View): HTMLElement { + const water = view.ground.groundwater; + if (!water.lines.length) return el("p", { className: "e02-empty", text: t("E02_Line_Empty") }); + const rows = water.lines.map((line, i) => { + const base = `groundwater.lines.${i}`; + const points = el("div", { className: "e02-points" }); + points.dataset.path = `${base}.points`; + line.points.forEach((point, k) => { + const pair = el("span", { className: "e02-point" }); + pair.append( + numInput(view, `${base}.points.${k}.0`, point[0], (v) => (point[0] = v as number)), + numInput(view, `${base}.points.${k}.1`, point[1], (v) => (point[1] = v as number)), + removeButton(view, () => { + line.points.splice(k, 1); + view.rebuild(); + }), + ); + points.append(pair); + }); + const addPoint = createButton({ + label: t("E02_Btn_AddPoint"), + variant: "ghost", + size: "sm", + onClick: () => { + const last = line.points[line.points.length - 1]; + line.points.push(last ? [last[0] + 5, last[1]] : [0, 1]); + view.rebuild(); + }, + }); + addPoint.hidden = !view.editable; + points.append(addPoint); + return [ + numInput(view, `${base}.from_m`, line.from_m, (v) => (line.from_m = v as number)), + numInput(view, `${base}.to_m`, line.to_m, (v) => (line.to_m = v as number)), + points, + removeButton(view, () => { + water.lines.splice(i, 1); + view.rebuild(); + }), + ]; + }); + return table([t("E02_Col_From"), t("E02_Col_To"), t("E02_Col_Points"), ""], rows); +} + +export function waterCard(view: E02View): HTMLElement { + const water = view.ground.groundwater; + const d = view.defaults.groundwater; + const rule = (key: (typeof WATER_KEYS)[number]): HTMLElement => + field( + L(`E02_Water_${key}`), + selectInput( + view, + `groundwater.${key}`, + WATER_RULES.map((r) => ({ value: r, text: L(`E02_Rule_${r}`) })), + water[key], + (v) => (water[key] = v as (typeof WATER_RULES)[number]), + { src: `groundwater.${key}`, def: L(`E02_Rule_${d[key]}`) }, + ), + ); + const mode = selectInput( + view, + "groundwater.wet_mode", + WET_MODES.map((m) => ({ value: m, text: L(`E02_WetMode_${m}`) })), + water.wet_mode, + (v) => { + water.wet_mode = v as WetMode; + view.rebuild(); + }, + { src: "groundwater.wet_mode", def: L(`E02_WetMode_${d.wet_mode}`) }, + ); + const grid = el("div", { className: "e02-grid" }); + grid.append(rule("dry"), field(t("E02_WetMode"), mode)); + const body: HTMLElement[] = []; + if (water.wet_mode === "kds") grid.append(rule("wet_soil"), rule("wet_rock")); + if (water.wet_mode === "depth_ratio") + grid.append( + field( + t("E02_DepthRatio"), + numInput( + view, + "groundwater.depth_ratio", + water.depth_ratio, + (v) => (water.depth_ratio = v as number), + { src: "groundwater.depth_ratio", def: d.depth_ratio }, + ), + ), + ); + if (water.wet_mode === "ru") + grid.append( + field( + t("E02_Ru"), + numInput(view, "groundwater.ru", water.ru, (v) => (water.ru = v as number), { + src: "groundwater.ru", + def: d.ru, + }), + ), + ); + if (water.wet_mode === "line") + body.push(el("p", { className: "e02-hint", text: t("E02_Line_Hint") }), linesBody(view)); + + const { section, head } = card( + "E02_Card_Water", + el("p", { className: "e02-hint", text: t("E02_Water_Hint") }), + grid, + ...body, + ); + const stations = view.ground.stations; + const first = stations[0]?.chainage_m ?? 0; + const last = stations[stations.length - 1]?.chainage_m ?? 100; + const buttons = [ + createButton({ + label: t("E02_Btn_Defaults"), + variant: "ghost", + size: "sm", + onClick: () => { + view.ground.groundwater = clone(d); + view.rebuild(); + }, + }), + ]; + if (water.wet_mode === "line") + buttons.unshift( + createButton({ + label: t("E02_Btn_AddLine"), + variant: "ghost", + size: "sm", + onClick: () => { + water.lines.push({ + from_m: first, + to_m: last > first ? last : first + 20, + points: [ + [-10, 1], + [10, 1], + ], + }); + view.rebuild(); + }, + }), + ); + headButtons(view, head, ...buttons); + return section; +} diff --git a/E02_StabGround/draft/E02_StabGround_Route.ts b/E02_StabGround/draft/E02_StabGround_Route.ts new file mode 100644 index 000000000..1bf53ce98 --- /dev/null +++ b/E02_StabGround/draft/E02_StabGround_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E02_StabGround_Route.ts + * 길 선언(PLAN 529-1 · 532-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E02 지반 조건 페이지 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E02_STAB_GROUND, + workflow: true, + project: true, + footerless: true, + 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..11c8bc1dc --- /dev/null +++ b/E02_StabGround/logic/E02_StabGround_Check.ts @@ -0,0 +1,230 @@ +/* ============================================================================= + * 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"; +/** 우기 지하수 갈래 — kds = wet_soil · wet_rock 규칙(기본) · depth_ratio · ru · line(파이썬 `WET_MODES`) */ +export type WetMode = "kds" | "depth_ratio" | "ru" | "line"; + +/** 사용자 수위선 한 구간 — 점 = [offset m(좌 +), 공사 뒤 지표 아래 깊이 m] · offset 오름차순 */ +export interface WaterLine { + from_m: number; + to_m: number; + points: [number, number][]; +} + +export interface Groundwater { + dry: WaterRule; + wet_soil: WaterRule; + wet_rock: WaterRule; + wet_mode: WetMode; + depth_ratio: number; + ru: number; + lines: WaterLine[]; +} + +/** 물성 한 줄 = 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: Groundwater; + 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"]; +export const WET_MODES: readonly WetMode[] = ["kds", "depth_ratio", "ru", "line"]; + +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"); + if (!WET_MODES.includes(water.wet_mode as WetMode)) bad("groundwater.wet_mode", "enum"); + ranged("groundwater.depth_ratio", water.depth_ratio, "depth_ratio"); + ranged("groundwater.ru", water.ru, "ru"); + if (!Array.isArray(water.lines)) bad("groundwater.lines", "type"); + asList(water.lines).forEach((line, i) => { + const base = `groundwater.lines.${i}`; + if (!isDict(line)) return bad(base, "type"); + ranged(`${base}.from_m`, line.from_m, "chainage_m"); + ranged(`${base}.to_m`, line.to_m, "chainage_m"); + if (isNum(line.from_m) && isNum(line.to_m) && line.from_m >= line.to_m) + bad(`${base}.to_m`, "order"); + const points = asList(line.points); + if (points.length < 2) bad(`${base}.points`, "few_points"); + let last: number | null = null; + points.forEach((point, k) => { + if (!Array.isArray(point) || point.length !== 2) return bad(`${base}.points.${k}`, "type"); + ranged(`${base}.points.${k}.0`, point[0], "offset_m"); + ranged(`${base}.points.${k}.1`, point[1], "water_depth_m"); + if (isNum(point[0])) { + if (last !== null && point[0] <= last) bad(`${base}.points.${k}.0`, "order"); + last = point[0]; + } + }); + }); + 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/E03_StabSection/E03_StabSection_Engine_Describe.py b/E03_StabSection/E03_StabSection_Engine_Describe.py new file mode 100644 index 000000000..eb2e9e3d9 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Describe.py @@ -0,0 +1,140 @@ +"""E03 결과 꾸미기 — 임계면 글상자 · 절편 자유물체도 · F–λ 곡선 · Safety Map 상위 면. + +좌표는 원래 방향(x = B06 offset · 좌 +)으로 되돌려 냄. 출력 칸은 덧붙이기만(E03 화면 · E05 보고서가 읽음). +""" + +import numpy as np + +from E03_StabSection import E03_StabSection_Engine_GLE as gle +from E03_StabSection import E03_StabSection_Engine_LEM as lem +from E03_StabSection import E03_StabSection_Engine_Support as support +from E03_StabSection.E03_StabSection_Engine_Surface import ( + circle_slices, + evaluate, + poly_slices, + three_point_center, +) + +#: Safety Map 면 수 · 면마다 꼭짓점 수(원호) +SAFETY_MAP_TOP = 50 +_MAP_POINTS = 25 + + +def _curve(sl, center, method, f=None, wide=False) -> dict: + """F_m · F_f 대 λ (화면 그래프) — Spencer = 상수 f · M-P = 반정현 · wide = 넓은 범위(해 없음이 보이게).""" + if method == "spencer": + rng, n = ( + (lem.LAMBDA_WIDE, lem.WIDE_POINTS) if wide else (lem.LAMBDA_RANGE, gle.CURVE_POINTS) + ) + grid, fm, ff = lem.lambda_curve(sl, center, np.linspace(*rng, n)) + fm, ff = fm[:, 0], ff[:, 0] + else: + grid = np.linspace(*gle.LAMBDA_WIDE, lem.WIDE_POINTS) if wide else None + grid, fm, ff = gle.lambda_curves(sl, center, f, grid) + fm, ff = fm[0], ff[0] + num = lambda v: [float(x) if np.isfinite(x) else None for x in v] # noqa: E731 + return {"lambda": num(grid), "Fm": num(fm), "Ff": num(ff)} + + +def describe(prep, kind, geom, method, n, min_depth, seed_F=None) -> dict: + """임계면 하나 → 안전율 · 좌표 · 절편표(자유물체도 힘) · 수렴 · (Spencer · M-P) λ · F–λ 곡선.""" + 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]]) + pts = np.column_stack([sl["xe"][0], 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]) + seed = None if seed_F is None else np.array([seed_F], float) + res = evaluate(sl, center, (method,), mp_seed=seed)[method] + F = float(res["F"][0]) + lam = float(res["lambda"][0]) if method in ("spencer", "morgenstern_price") else 0.0 + f = gle.interslice_function(sl["xe"]) if method == "morgenstern_price" else None + rows = lem.slice_table(sl, 0, F, lam, method, f) if np.isfinite(F) else [] + for r in rows: + r["material"] = ( + prep.materials[r["material"]]["id"] if r["material"] < len(prep.materials) else None + ) + # 행 순서 = 머리 → 끝 · E · X 의 left = 머리 쪽 경계 · right = 끝 쪽 경계(해석 틀 그대로) + r["x_left"], r["x_right"] = sorted((s * r["x_left"], s * r["x_right"])) + r["x_mid"] = s * r["x_mid"] + 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]) + else: + out["moment_center"] = [float(s * center[0, 0]), float(center[0, 1])] + if method in ("spencer", "morgenstern_price"): + # 수렴 정보 — λ · 반복 수 · 잔차(max |F_m − F|, |F_f − F|) · 상태 · 해 없음 까닭 + solved = np.isfinite(F) + wide = bool(res["wide"][0]) + rng = ( + (lem.LAMBDA_WIDE if wide else lem.LAMBDA_RANGE) + if method == "spencer" + else (gle.LAMBDA_WIDE if wide else gle.LAMBDA_RANGE) + ) + out["lambda"] = lam if solved else None + out["theta_deg"] = float(np.degrees(np.arctan(lam))) if solved else None + r = float(res["residual"][0]) + out["residual"] = r if np.isfinite(r) else None + out["lambda_range"] = [float(rng[0]), float(rng[1])] + crossed = bool(res["converged"][0]) + out["status"] = "converged" if solved else ("rejected" if crossed else "no_solution") + out["reason"] = ( + None + if solved + else "해는 있으나 버림(m_α < 0.2 · 저항 없음 · F 하한)" + if crossed + else f"F_m · F_f 가 λ {rng[0]:.2f} ~ {rng[1]:.2f} 안에서 안 만남(해 없음)" + ) + out["lambda_curve"] = _curve(sl, center, method, f, wide or not solved) + if method == "morgenstern_price": + out["interslice_function"] = "half_sine" + 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]) + if prep.supports is not None: + out["support_forces"] = ( + support.forces(sl, 0, F, prep.supports["active"], s) if np.isfinite(F) else [] + ) + return out + + +def safety_map(prep, circles, F, method, top=SAFETY_MAP_TOP) -> dict: + """Safety Map — 거친 탐색 원호 중 안전율 낮은 top 개(F · 중심 · 반지름 · 진입 · 진출 · 꼭짓점).""" + s = prep.direction + xc, zc, R, xE, xX = circles + order = np.argsort(np.where(np.isfinite(F), F, np.inf))[:top] + order = order[np.isfinite(F[order])] + t = np.linspace(0.0, 1.0, _MAP_POINTS) + out = [] + for k in order: + x = xE[k] + (xX[k] - xE[k]) * t + z = zc[k] - np.sqrt(np.clip(R[k] ** 2 - (x - xc[k]) ** 2, 0, None)) + pts = np.column_stack([s * x, z]) + out.append( + { + "F": float(F[k]), + "center": [float(s * xc[k]), float(zc[k])], + "radius": float(R[k]), + "entry": [float(s * xE[k]), float(z[0])], + "exit": [float(s * xX[k]), float(z[-1])], + "points": [[float(a), float(b)] for a, b in (pts if s > 0 else pts[::-1])], + } + ) + return {"method": method, "surfaces": out} diff --git a/E03_StabSection/E03_StabSection_Engine_GLE.py b/E03_StabSection/E03_StabSection_Engine_GLE.py new file mode 100644 index 000000000..c6c86962e --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_GLE.py @@ -0,0 +1,220 @@ +"""E03 엄밀해 — Morgenstern-Price(GLE) · 절편간 힘 X = λ·f(x)·E · f = 반정현(기본) 또는 상수. + +식 = LEM 과 같은 절편 평형(`E03_StabSection_Engine_LEM` 머리글) — 절편간 힘 함수만 경계마다 다름: + 절편 i 연직 평형 → N·(m_α − λ·f_{i+1}·A) = W − c'·sinα/F − X_i + λ·f_{i+1}·(E_i + B0) + 수평 평형 → E_{i+1} = E_i + B0 + N·A · X_{i+1} = λ·f_{i+1}·E_{i+1} · 머리 E_0 = 균열 수압 · X_0 = 0 + f ≡ 1 이면 Spencer 와 같은 식(시험으로 확인). F_m · F_f 는 LEM 과 같은 전체 평형식. +풀이 = λ 훑기(F_m(λ) · F_f(λ) 곡선 — 화면 그래프 자료) → 교점 → (F, λ) 2 차 뉴턴(수치 야코비안). +계산은 임계면 몇 개만(탐색 고리 밖) — 면을 λ 갯수만큼 복제해 한 번에 셈. 외부 코드 없음(NumPy 만). +""" + +import numpy as np + +from E03_StabSection import E03_StabSection_Engine_LEM as lem + +FUNCTIONS = ("half_sine", "constant") +_TINY = 1e-9 +CURVE_POINTS = 21 +#: M-P λ 범위 — 반정현 평균 2/π 라 평균 X/E 가 Spencer 범위(LEM.LAMBDA_RANGE)와 같게 π/2 배 +LAMBDA_RANGE = tuple(v * np.pi / 2 for v in lem.LAMBDA_RANGE) +#: 위 범위에 해가 없을 때만 다시 찾는 넓은 범위(같은 π/2 배 · 거친 훑기 → 뉴턴) — 그래도 없으면 「해 없음」 +LAMBDA_WIDE = tuple(v * np.pi / 2 for v in lem.LAMBDA_WIDE) + + +def interslice_function(xe: np.ndarray, kind: str = "half_sine") -> np.ndarray: + """절편 경계마다 f(x) — 반정현 sin(π·(x − x_진입)/(x_진출 − x_진입)) · 상수 1.""" + if kind == "constant": + return np.ones_like(xe) + if kind != "half_sine": + raise ValueError(f"절편간 힘 함수 모름: {kind}") + x0, x1 = xe[:, :1], xe[:, -1:] + return np.sin(np.pi * (xe - x0) / np.maximum(x1 - x0, _TINY)) + + +def forces(sl: dict, F, lam, f): + """절편마다 N · 분모 · 경계 E · X (꼴 (S, n) · (S, n) · (S, n+1) · (S, n+1)) — 머리부터 차례로.""" + p = lem._pre(sl) + F = np.asarray(F, float) + lam = np.asarray(lam, float) + g = 1.0 / F[:, None] + m = sl["ca"] + g * p["sat"] + A = sl["sa"] - g * p["tca"] + B0 = p["hz"] - g * p["cuca_n"] + rhs = p["wv"] - g * p["cusa_n"] # 보강 칸 포함(LEM `_pre`) + S, n = rhs.shape + N = np.empty((S, n)) + den = np.empty((S, n)) + E = np.empty((S, n + 1)) + X = np.zeros((S, n + 1)) + E[:, 0] = sl["Pw"] + for i in range(n): + lf = lam * f[:, i + 1] + d = m[:, i] - lf * A[:, i] + den[:, i] = d + N[:, i] = (rhs[:, i] - X[:, i] + lf * (E[:, i] + B0[:, i])) / np.where( + np.abs(d) < _TINY, _TINY, d + ) + E[:, i + 1] = E[:, i] + B0[:, i] + N[:, i] * A[:, i] + X[:, i + 1] = lf * E[:, i + 1] + return N, den, E, X + + +def _fm_ff(sl, center, F, lam, f): + N, den, *_ = forces(sl, F, lam, f) + at, gn, d0, ma = lem._mom(sl, center) + p = lem._pre(sl) + fm = lem._ratio(ma + (at * N).sum(1), d0 + (gn * N).sum(1)) + ff = lem._ratio(p["rc0"] + (p["tca"] * N).sum(1), p["fd0"] + (sl["sa"] * N).sum(1), neg=False) + return fm, ff, den + + +def _repeat(sl: dict, k: int) -> dict: + """면마다 k 번 복제(λ 훑기 · 야코비안을 한 번에) — 미리 묶은 칸은 버림.""" + S = len(sl["W"]) + return { + key: (np.repeat(v, k, axis=0) if isinstance(v, np.ndarray) and v.shape[:1] == (S,) else v) + for key, v in sl.items() + if key not in ("_pre", "_mom") + } + + +def _solve_f(fun, F0, iters=60): + """F = fun(F) — 수치 미분 뉴턴 · 안 되면 반쯤 섞은 고정점(LEM `_iterate` 와 같은 안전장치).""" + F = np.asarray(F0, float).copy() + done = np.zeros(len(F), bool) + for _ in range(iters): + f1 = fun(F) + h1 = f1 - F + dlt = 1e-6 * np.maximum(np.abs(F), 0.1) + dh = ((fun(F + dlt) - (F + dlt)) - h1) / dlt + newton = F - h1 / np.where(np.abs(dh) < 1e-12, -1.0, dh) + use = np.isfinite(newton) & (dh < -1e-9) & (newton > 0.5 * F) & (newton < 2.0 * F) + Fn = np.where(use, newton, 0.5 * (F + f1)) + Fn = np.where(np.isfinite(Fn) & (Fn > 0), Fn, np.nan) + conv = np.abs(h1) < lem.TOL * np.maximum(1.0, np.abs(F)) + F = np.where(done | conv, F, Fn) + done |= conv | ~np.isfinite(F) + if done.all(): + break + return np.where(done, F, np.nan) + + +def lambda_curves(sl, center, f, grid=None, F0=None): + """λ 마다 F_m(λ) · F_f(λ) — 꼴 (G,) · (S, G) · (S, G).""" + grid = np.linspace(*LAMBDA_RANGE, CURVE_POINTS) if grid is None else np.asarray(grid) + S, G = len(sl["W"]), len(grid) + rep = _repeat(sl, G) + rc = np.repeat(center, G, axis=0) + rf = np.repeat(f, G, axis=0) + lam = np.tile(grid, S) + start = np.full(S * G, 1.5) if F0 is None else np.repeat(F0, G) + fm = _solve_f(lambda F: _fm_ff(rep, rc, F, lam, rf)[0], start) + ff = _solve_f(lambda F: _fm_ff(rep, rc, F, lam, rf)[1], start) + return grid, fm.reshape(S, G), ff.reshape(S, G) + + +def _crossing(grid, fm, ff): + """F_m − F_f 부호가 바뀌는 곳 중 λ = 0 가까운 쪽 → (F, λ) · 없으면 nan.""" + S = len(fm) + F, L = np.full(S, np.nan), np.full(S, np.nan) + best = np.full(S, np.inf) + d = fm - ff + for k in range(len(grid) - 1): + d0, d1 = d[:, k], d[:, 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) + lc = grid[k] + t * (grid[k + 1] - grid[k]) + fc = fm[:, k] + t * (fm[:, k + 1] - fm[:, k]) + better = cross & (np.abs(lc) < best) + best = np.where(better, np.abs(lc), best) + F, L = np.where(better, fc, F), np.where(better, lc, L) + return F, L + + +def _newton2(sl, center, f, F, lam, iters=40): + """(F_m − F, F_f − F) = 0 — 세 벌 복제(F · F+h · λ+h)로 수치 야코비안 · 걸음 제한 LEM Spencer 와 같음.""" + S = len(F) + rep, rc, rf = _repeat(sl, 3), np.repeat(center, 3, axis=0), np.repeat(f, 3, axis=0) + F, lam = F.copy(), lam.copy() + done = ~np.isfinite(F) + it = np.zeros(S, int) + for _ in range(iters): + hF = 1e-6 * np.maximum(np.abs(F), 0.1) + hl = 1e-6 + Fs = np.column_stack([F, F + hF, F]).ravel() + ls = np.column_stack([lam, lam, lam + hl]).ravel() + fm, ff, _ = _fm_ff(rep, rc, np.where(np.isfinite(Fs), Fs, 1.0), ls, rf) + rm = (fm - Fs).reshape(S, 3) + rf_ = (ff - Fs).reshape(S, 3) + j11, j12 = (rm[:, 1] - rm[:, 0]) / hF, (rm[:, 2] - rm[:, 0]) / hl + j21, j22 = (rf_[:, 1] - rf_[:, 0]) / hF, (rf_[:, 2] - rf_[:, 0]) / hl + det = j11 * j22 - j12 * j21 + det = np.where(np.abs(det) < 1e-14, np.nan, det) + dF = -(j22 * rm[:, 0] - j12 * rf_[:, 0]) / det + dl = -(-j21 * rm[:, 0] + j11 * rf_[:, 0]) / det + scale = np.minimum( + 1.0, + np.minimum( + 0.5 * np.abs(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) < lem.TOL * np.maximum(1, np.abs(F))) & (np.abs(dl) < lem.TOL) + done |= small | ~np.isfinite(F) | (F < lem.F_MIN) + if done.all(): + break + return F, lam, it + + +def _solve(sl, center, f, grid, fm, ff, F0, rng): + """곡선 교점 → 뉴턴 → (F, λ, 반복, 수렴, 잔차) — 수렴 = 두 잔차 < 1e-4 · λ 범위 안 · 참고값 0.5~2 배.""" + F, lam = _crossing(grid, fm, ff) + F, lam, it = _newton2(sl, center, f, F, lam) + ok = np.isfinite(F) + safe = np.where(ok & (F > 0), F, 1.0) + rm, rff, _ = _fm_ff(sl, center, safe, np.where(ok, lam, 0.0), f) + res = np.where(ok, np.maximum(np.abs(rm - F), np.abs(rff - F)), np.nan) + conv = ok & (res < 1e-4) & (lam >= rng[0]) & (lam <= rng[1]) + if F0 is not None: + conv &= (F > 0.5 * F0) & (F < 2.0 * F0) + return F, lam, it, conv, res + + +def morgenstern_price(sl: dict, center: np.ndarray, kind="half_sine", F0=None, curve=True): + """M-P (F, λ) · 수렴 · (curve) F_m · F_f–λ 곡선. F0 = 출발 참고값(Spencer · Bishop) — 엉뚱한 근 거름.""" + f = interslice_function(sl["xe"], kind) + grid, fm, ff = lambda_curves(sl, center, f, F0=F0) + F, lam, it, conv, res = _solve(sl, center, f, grid, fm, ff, F0, LAMBDA_RANGE) + wide = ~conv & sl["valid"] + if wide.any(): + # 좁은 범위에 해 없음 → 넓은 범위 거친 훑기 → 뉴턴(그 면만) + sub, cw, fw = lem._subset(sl, wide), center[wide], f[wide] + g2 = np.linspace(*LAMBDA_WIDE, lem.WIDE_POINTS) + F0w = None if F0 is None else np.asarray(F0, float)[wide] + _, fm2, ff2 = lambda_curves(sub, cw, fw, g2, F0w) + F2, l2, it2, c2, r2 = _solve(sub, cw, fw, g2, fm2, ff2, F0w, LAMBDA_WIDE) + F[wide], lam[wide], it[wide], conv[wide], res[wide] = F2, l2, it[wide] + it2, c2, r2 + ok = np.isfinite(F) + safe = np.where(ok & (F > 0), F, 1.0) + rm, rff, den = _fm_ff(sl, center, safe, np.where(ok, lam, 0.0), f) + p = lem._pre(sl) + N = forces(sl, safe, np.where(ok, lam, 0.0), f)[0] + malpha_ok = np.all(den >= lem.MALPHA_MIN, axis=1) + R = p["cu"] + N * sl["tanphi"] + good = sl["valid"] & conv & (F > lem.F_MIN) & malpha_ok & (R.sum(1) > 0) + out = { + "F": np.where(good, F, np.nan), + "lambda": np.where(good, lam, np.nan), + "iterations": it, + "converged": conv, + "malpha_ok": malpha_ok, + "residual": res, + "wide": wide, + "function": kind, + } + if curve: + out["curve"] = (grid, fm, ff) + return out diff --git a/E03_StabSection/E03_StabSection_Engine_LEM.py b/E03_StabSection/E03_StabSection_Engine_LEM.py new file mode 100644 index 000000000..e5ea8e44c --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_LEM.py @@ -0,0 +1,695 @@ +"""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φ +보강재(`Engine_Support`) = 절편 바닥 점하중 — 능동은 W · kh·Ws 칸에 더함 · 수동(힘 / F)은 c' 칸(1/F 곱)에서 빼고 +전체 평형의 수동 항은 분자로 옮김(F = R/(D − T/F) ⇔ F = (R + T)/D · 같은 근). +외부 코드 없음(NumPy 만) — 상용 · 오픈소스 결과는 대조 시험에서만 씀. +""" + +import numpy as np + +from E03_StabSection import E03_StabSection_Engine_Support as support +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) +#: 위 범위에 해가 없을 때만 다시 찾는 넓은 범위(거친 훑기 → 뉴턴) — 그래도 없으면 「해 없음」 +LAMBDA_WIDE = (-2.0, 2.0) +WIDE_POINTS = 41 +#: 이보다 작은 F = 1/F 항이 터진 엉뚱한 근(실제 비탈 F 는 이 위) — 버림. +F_MIN = 0.05 +TOL = 1e-6 +#: Janbu 보정 f0 식의 d/L 상한(지식DB 02 §2.3) +JANBU_DL_MAX = 0.35 +MAX_ITER = 100 +WALL_ID = "wall" # Engine_Model.WALL_MATERIAL id + + +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]) + # 벽(옹벽 · 석축) — 바닥이 벽 안을 지나는 활동면은 버림(벽 밑 · 뒤로 돌아가는 면만 후보) + self.blocked = np.array([m["id"] == WALL_ID for m in mats] + [False]) + 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) + self.supports = support.prepare(model.get("supports"), self.direction) + + 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) + # 빈 구간은 정렬 뒤 끝으로 — 어느 띠에도 안 쓰인 끝 칸을 잘라 절편 계산 폭을 줄임(값 같음) + used = ~(np.isnan(self.bot_a) & np.isnan(self.bot_b)) + k = int(np.max(np.nonzero(used.any(0))[0], initial=0)) + 1 + for name in ("bot_a", "bot_b", "top_a", "top_b", "mat"): + setattr(self, name, getattr(self, name)[:, :k]) + # 지표면 꺾은선(정확) — 띠 양 끝으로 늘린 맨 윗면 · 연직 면은 같은 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)) + # 절편 계산이 한 번에 모아 읽는 표 (띠, 6, 구간) — 바닥 · 바닥 기울기 · 윗면 · 윗면 기울기 · γt · γsat − γt + gt, gs = self.gamma_t[self.mat], self.gamma_sat[self.mat] + self.table = np.stack( + [self.bot_a, self.bot_b - self.bot_a, self.top_a, self.top_b - self.top_a, gt, gs - gt], + axis=1, + ) + + # ---- 조회 ------------------------------------------------------------- + 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)) + j = np.clip(np.searchsorted(prep.X, xm, side="right") - 1, 0, len(prep.xa) - 1) + t = ((xm - prep.xa[j]) / (prep.xb[j] - prep.xa[j]))[..., None] + G = prep.table[j] + mat = prep.mat[j] + bot = G[..., 0, :] + t * G[..., 1, :] + top = G[..., 2, :] + t * G[..., 3, :] + gt, dgs = G[..., 4, :], G[..., 5, :] + 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) + u = GAMMA_W * np.clip(zw - zb, 0.0, None) + weight_per_b = np.sum(gt * thick + dgs * wet, axis=-1) + else: + wet = None + u = np.zeros_like(zb) + weight_per_b = np.sum(gt * thick, axis=-1) + if prep.kh > 0: # 무게중심 높이는 지진 관성력 팔에만 쓰임 + if wet is None: + moment_z = np.sum(gt * thick * (lo + 0.5 * thick), axis=-1) + else: + dry = thick - wet + moment_z = np.sum( + gt * dry * (lo + wet + 0.5 * dry) + (gt + dgs) * wet * (lo + 0.5 * wet), axis=-1 + ) + zg = np.where(weight_per_b > 0, moment_z / np.maximum(weight_per_b, _TINY), zb) + else: + zg = 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) + valid &= ~np.any(prep.blocked[base_mat] & found, axis=1) + out = { + "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(), + } + if prep.supports is not None: + support.apply(out, prep.supports) + return out + + +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 + + +# ---- 방법 ------------------------------------------------------------------- +# 한 벌 식을 F 와 무관한 칸(_pre) · 모멘트 중심 칸(_mom) 으로 미리 묶어 두고, F · λ 마다는 N 만 새로 셈. +# g = 1/F 로 놓으면 N = (P0 − g·P1)/(Q0 + g·Q1) — dN/dg · dN/dλ 가 닫힌 꼴이라 뉴턴 야코비안을 해석적으로 씀. +def _pre(sl: dict) -> dict: + p = sl.get("_pre") + if p is None: + t, sa, ca = sl["tanphi"], sl["sa"], sl["ca"] + cu = sl["c"] * sl["l"] - sl["u"] * sl["l"] * t + khws = sl["kh"] * sl["Ws"] + # 보강 — 능동은 연직 · 수평 하중에 더함 · 수동(× 1/F)은 c' 칸(g 곱)에서 뺌 · 없으면 같은 배열 + sup = "sup_va" in sl + wv = sl["W"] + sl["sup_va"] if sup else sl["W"] + hz = khws + sl["sup_ha"] if sup else khws + lw = hz.copy() + lw[:, 0] += sl["Pw"] # 머리 경계 수압은 전단 없음 → X_0 = 0 + p = { + "cu": cu, + "cusa": cu * sa, + "cuca": cu * ca, + "sat": sa * t, + "tca": t * ca, + "khws": khws, + "lw": lw, + "fd0": hz.sum(1) + sl["Pw"], + "rc0": (cu * ca).sum(1) - (sl["sup_hp"].sum(1) if sup else 0.0), + "wv": wv, + "hz": hz, + "cusa_n": cu * sa - sl["sup_vp"] if sup else cu * sa, + "cuca_n": cu * ca - sl["sup_hp"] if sup else cu * ca, + } + sl["_pre"] = p + return p + + +def _mom(sl: dict, center: np.ndarray): + """모멘트 팔(a) · 분모 N 계수(gN) · 상수 칸 — 같은 중심 배열이면 다시 쓰고 아니면 새로 셈.""" + cached = sl.get("_mom") + if cached is not None and cached[0] is center: + return cached[1] + p = _pre(sl) + xo, zo = center[:, 0:1], center[:, 1:2] + dx, dz = sl["xm"] - xo, sl["zb"] - zo + a = dx * sl["sa"] + dz * sl["ca"] + gn = dz * sl["sa"] - dx * sl["ca"] + d0 = ((sl["xw"] - xo) * sl["W"] + (sl["zg"] - zo) * p["khws"]).sum(1) + d0 = d0 + (sl["zp"] - center[:, 1]) * sl["Pw"] + ma = (a * p["cu"]).sum(1) + if "sup_va" in sl: # 능동 = 활동 모멘트 · 수동(/F) = 분자로 옮김 + d0 = d0 + _sup_moment(sl, xo, zo, "a") + ma = ma - _sup_moment(sl, xo, zo, "p") + out = (a * sl["tanphi"], gn, d0, ma) + sl["_mom"] = (center, out) + return out + + +def _sup_moment(sl, xo, zo, tag): + """보강 점하중의 활동 모멘트 Σ (x − xo)·연직(아래 +) + (z − zo)·수평(+x).""" + v, h = sl[f"sup_v{tag}"], sl[f"sup_h{tag}"] + return (sl[f"sup_xv{tag}"] - xo * v + sl[f"sup_zh{tag}"] - zo * h).sum(1) + + +def _normal(sl, F, lam, grad=0): + """절편 수직력 N · 분모(m_α − λA) · grad ≥ 1 dN/dg · grad 2 dN/dλ — F · λ 꼴 (S,).""" + p = _pre(sl) + g = 1.0 / np.asarray(F, float)[:, None] + L = np.asarray(lam, float)[:, None] + if L.any(): + P1 = p["cusa_n"] + L * p["cuca_n"] + Q1 = p["sat"] + L * p["tca"] + den = sl["ca"] - L * sl["sa"] + g * Q1 + top = p["wv"] + L * p["lw"] + else: # λ = 0(Bishop · Janbu) — 같은 식에서 λ 칸만 뺌 + P1, Q1 = p["cusa_n"], p["sat"] + den = sl["ca"] + g * Q1 + top = p["wv"] + safe = np.where(np.abs(den) < _TINY, _TINY, den) + N = (top - g * P1) / safe + if not grad: + return N, den + dg = -(P1 + N * Q1) / safe + if grad == 1: + return N, den, dg, None + dl = (p["lw"] - g * p["cuca_n"] + N * (sl["sa"] - g * p["tca"])) / safe + return N, den, dg, dl + + +def _parts(sl: dict, F, lam): + """주어진 F · λ 의 N · 저항 R · 분모 · A · B0(절편표용). + + 유효 수직력 음(인장) 절편도 그대로 둠(Slide2 와 같음 — 0 으로 자르면 ACADS · Arai & Tagyo + 대조가 3~6 % 벗어남). 대신 저항 합이 0 이하인 면은 `_finish` 가 버림. + """ + p = _pre(sl) + F = np.asarray(F, float) + N, den = _normal(sl, F, lam) + g = 1.0 / F[:, None] + A = sl["sa"] - p["tca"] * g + B0 = p["hz"] - p["cuca_n"] * g + return N, p["cu"] + N * sl["tanphi"], den, A, B0 + + +def _ratio(num, den, dnum=None, dden=None, neg=True): + """f = num/den (작은 분모는 ∓TINY) · 도함수 있으면 (f, df).""" + d = np.where(np.abs(den) < _TINY, -_TINY if neg else _TINY, den) + f = num / d + return f if dnum is None else (f, (dnum - f * dden) / d) + + +def _balance(cf, cd, c0, d0, nd, F, neg): + """f = (c0 + Σcf·N)/(d0 + Σcd·N) 와 df/dF · df/dλ (nd = _normal 결과 · 없는 도함수는 None).""" + N, _den, dg, dl = nd + num, den = c0 + (cf * N).sum(1), d0 + (cd * N).sum(1) + if dg is None: + return _ratio(num, den, neg=neg), None, None + f, fg = _ratio(num, den, (cf * dg).sum(1), (cd * dg).sum(1), neg) + fl = None if dl is None else _ratio(num, den, (cf * dl).sum(1), (cd * dl).sum(1), neg)[1] + return f, -fg / np.asarray(F, float) ** 2, fl + + +def _fm(sl, center, F, lam, grad=0, nd=None): + """모멘트 평형 F_m = Σ a·R / D → (F_m, dF_m/dF, dF_m/dλ, 분모).""" + at, gn, d0, ma = _mom(sl, center) + nd = nd or _normal(sl, F, lam, grad) + if len(nd) == 2: + nd = (*nd, None, None) + return (*_balance(at, gn, ma, d0, nd, F, True), nd[1]) + + +def _ff(sl, F, lam, grad=0, nd=None): + """힘 평형 F_f = Σ R·cosα / (Σ(N·sinα + kh·Ws) + Pw) → (F_f, dF_f/dF, dF_f/dλ, 분모).""" + p = _pre(sl) + nd = nd or _normal(sl, F, lam, grad) + if len(nd) == 2: + nd = (*nd, None, None) + return (*_balance(p["tca"], sl["sa"], p["rc0"], p["fd0"], nd, F, False), nd[1]) + + +def _iterate(sl, center, lam, F0, moment=True): + """F = f(F) 풀이 — f = 모멘트(F_m) 또는 힘(F_f) 평형 · 해석 도함수 뉴턴 · 안 되면 반쯤 섞은 고정점 걸음. + + 단순 고정점은 급한 머리 절편에서 진동해 느림(도함수 ≈ −1) — 뉴턴은 몇 번에 끝남. + 끝난 면은 빼고 셈(남은 면이 절반 아래면 묶음을 줄임 · 면마다 따로 셈이라 값 같음). + """ + F = np.asarray(F0, float).copy() + done = np.zeros(len(F), bool) + iters = np.zeros(len(F), int) + idx, w, wc, wl = np.arange(len(F)), sl, center, lam + for _ in range(MAX_ITER): + Fi, di = F[idx], done[idx] + f1, df = (_fm(w, wc, Fi, wl, 1) if moment else _ff(w, Fi, wl, 1))[:2] + h1 = f1 - Fi + dh = df - 1.0 + newton = Fi - h1 / np.where(np.abs(dh) < 1e-12, -1.0, dh) + relaxed = 0.5 * (Fi + f1) + use = np.isfinite(newton) & (dh < -1e-9) & (newton > 0.5 * Fi) & (newton < 2.0 * Fi) + 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(Fi)) + F[idx] = np.where(di | conv, Fi, Fn) + iters[idx] += ~di + done[idx] = di | conv | ~np.isfinite(F[idx]) + act = ~done[idx] + if not act.any(): + break + if act.sum() < 0.5 * len(idx): + idx, w = idx[act], _subset(w, act) + wc = None if wc is None else wc[act] + wl = wl[act] + return F, iters, done & np.isfinite(F) + + +def _ordinary_normal(sl: dict, F=None): + """Fellenius 바닥 수직 분해 N = W·cosα − kh·Ws·sinα (+ 보강 · 수동은 / F).""" + p = _pre(sl) + N = p["wv"] * sl["ca"] - p["hz"] * sl["sa"] + if "sup_vp" in sl and F is not None: + N = N + (sl["sup_vp"] * sl["ca"] - sl["sup_hp"] * sl["sa"]) / np.asarray(F, float)[:, None] + return N + + +def ordinary(sl: dict, center: np.ndarray) -> dict: + """Fellenius — 절편간 힘 무시 · 바닥 수직 분해 · 모멘트 평형(수동 보강이 있으면 N 이 F 를 따라 고정점 반복).""" + at, gn, d0, ma = _mom(sl, center) + F = np.full(len(sl["W"]), 1.5) + for _ in range(MAX_ITER if "sup_vp" in sl and sl["sup_vp"].any() else 1): + N = _ordinary_normal(sl, F) + F_new = _ratio(ma + (at * N).sum(1), d0 + (gn * N).sum(1)) + done = np.abs(F_new - F) < TOL * np.maximum(1.0, np.abs(F)) + F = np.where(np.isfinite(F_new) & (F_new > 0), F_new, F) + if done.all(): + break + F = F_new + ok = ( + sl["valid"] + & np.isfinite(F) + & (F > F_MIN) + & ((_pre(sl)["cu"] + N * sl["tanphi"]).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(sl, center, 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(sl, None, lam, F0, moment=False) + 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 + ) + # d/L ≤ 0.35 에서만 식이 맞음(지식DB 02 §2.3) — 넘으면 0.35 값(f0 곡선 꼭대기 근처) + ratio = np.minimum(dist.max(1) / np.maximum(L, _TINY), JANBU_DL_MAX) + 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, res = _spencer_newton(sl, center, F, lam) + conv &= near(F, start) + wide = np.zeros(S, bool) + for grid, rng in ( + (None, LAMBDA_RANGE), + (np.linspace(*LAMBDA_WIDE, WIDE_POINTS), LAMBDA_WIDE), + ): + bad = ~conv & sl["valid"] + if not (scan and bad.any()): + break + sub = _subset(sl, bad) + Fs, ls = _spencer_scan(sub, center[bad], grid) + F2, l2, it2, c2, r2 = _spencer_newton(sub, center[bad], Fs, ls, rng=rng) + F[bad], lam[bad], it[bad], res[bad] = F2, l2, it[bad] + it2, r2 + conv[bad] = c2 & near(F2, start[bad]) + wide[bad] = rng is LAMBDA_WIDE + out = _finish(sl, F, it, conv, lam) + out["lambda"] = np.where(np.isfinite(out["F"]), lam, np.nan) + out["residual"] = res + out["wide"] = wide + return out + + +def _spencer_newton(sl, center, F, lam, iters=25, rng=LAMBDA_RANGE): + """(F_m − F, F_f − F) = 0 뉴턴 — 걸음 크기 제한(F 절반 · λ 0.3) · 수렴 = 두 잔차 < 1e-4 · λ 범위 안. + + 끝난 면은 빼고(남은 면이 절반 아래면 묶음을 줄임) · F < F_MIN 으로 빠진 면은 버림(어차피 `_finish` 가 버림). + """ + F, lam = F.copy(), lam.copy() + it = np.zeros(len(F), int) + done = np.zeros(len(F), bool) + idx, w, wc = np.arange(len(F)), sl, center + for _ in range(iters): + act = ~done[idx] + if not act.any(): + break + if act.sum() < 0.5 * len(idx): + idx, w, wc = idx[act], _subset(w, act), wc[act] + f, lm, dn = F[idx], lam[idx], done[idx] + nd = _normal(w, f, lm, 2) + fm, fmF, fml, _ = _fm(w, wc, f, lm, nd=nd) + ff, ffF, ffl, _ = _ff(w, f, lm, nd=nd) + rm, rf = fm - f, ff - f + j11, j12, j21, j22 = fmF - 1.0, fml, ffF - 1.0, ffl + 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(dn, f, f + scale * dF) + F[idx], lam[idx] = f, np.where(dn, lm, lm + scale * dl) + it[idx] += ~dn + small = (np.abs(dF) < TOL * np.maximum(1, np.abs(f))) & (np.abs(dl) < TOL) + done[idx] = dn | small | ~np.isfinite(f) | (f < F_MIN) + nd = _normal(sl, F, lam) + rm = _fm(sl, center, F, lam, nd=nd)[0] - F + rf = _ff(sl, F, lam, nd=nd)[0] - F + conv = done & np.isfinite(F) & (F > 0) & (np.abs(rm) < 1e-4) & (np.abs(rf) < 1e-4) + conv &= (lam >= rng[0]) & (lam <= rng[1]) + return F, lam, it, conv, np.maximum(np.abs(rm), np.abs(rf)) + + +def lambda_curve(sl, center, grid=None): + """λ 마다 모멘트 · 힘 평형 F_m(λ) · F_f(λ) — 꼴 (len(grid), S) 둘(화면 F–λ 그래프 · 훑기).""" + grid = np.linspace(LAMBDA_RANGE[0], LAMBDA_RANGE[1], 16) if grid is None else grid + S = len(sl["W"]) + fm_all = np.full((len(grid), S), np.nan) + ff_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_all[k] = _iterate(sl, center, lam, F0)[0] + ff_all[k] = _iterate(sl, None, lam, F0, moment=False)[0] + return grid, fm_all, ff_all + + +def _spencer_scan(sl, center, grid=None): + """λ 를 훑어 F_m(λ) − F_f(λ) 부호가 바뀌는 곳(λ=0 가까운 쪽)을 출발점으로.""" + grid, fm_all, ff_all = lambda_curve(sl, center, grid) + S = len(sl["W"]) + diff = fm_all - ff_all + 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: + """면 묶음 일부 — 미리 묶은 칸(_pre · _mom)은 버림(부분 꼴이 달라 다시 셈).""" + return { + k: (v[mask] if isinstance(v, np.ndarray) and v.shape[:1] == mask.shape else v) + for k, v in sl.items() + if k not in ("_pre", "_mom") + } + + +def _finish(sl, F, it, conv, lam): + Fs = np.where(np.isfinite(F) & (F > 0), F, 1.0) + N, den = _normal(sl, Fs, lam) + R = _pre(sl)["cu"] + N * sl["tanphi"] + 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", f=None +) -> list[dict]: + """한 면(k 번째)의 절편표 + 자유물체도 힘 — 행 = 머리 → 끝 · left = 머리 쪽 경계. + + 폭 · 높이 · 경사 · 무게 · 수압 · 강도 · N(전) · N' · 동원 전단 · 저항 · 지진력 · 경계 E · X. + M-P 는 f(경계마다 절편간 힘 함수)를 받아 차례 계산(`Engine_GLE.forces`). + """ + one = _subset(sl, np.arange(len(sl["W"])) == k) + if method == "ordinary": + N = _ordinary_normal(one, np.array([F])) + E = np.zeros((1, N.shape[1] + 1)) + X = E + elif method == "morgenstern_price": + from E03_StabSection.E03_StabSection_Engine_GLE import forces + + N, _den, E, X = forces(one, np.array([F]), np.array([lam]), f[k : k + 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) + X = lam * E + X[:, 0] = 0.0 + R = one["c"] * one["l"] + (N - one["u"] * one["l"]) * one["tanphi"] + rows = [] + for i in range(N.shape[1]): + ul = float(one["u"][0, i] * one["l"][0, i]) + 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]) - ul, + "shear_mobilized": float(R[0, i] / F), + "E_left": float(E[0, i]), + "X_left": float(X[0, i]), + # 덧(2차) — 자유물체도 + "x_mid": float(one["xm"][0, i]), + "z_base": float(one["zb"][0, i]), + "z_top": float(one["zt"][0, i]), + "normal_total": float(N[0, i]), + "pore_force": ul, + "shear_strength": float(R[0, i]), + "seismic_force": float(one["kh"] * one["Ws"][0, i]), + "E_right": float(E[0, i + 1]), + "X_right": float(X[0, i + 1]), + } + ) + 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_Prob.py b/E03_StabSection/E03_StabSection_Engine_Prob.py new file mode 100644 index 000000000..fc6e913c0 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Prob.py @@ -0,0 +1,441 @@ +"""E03 2D 확률 해석 · 민감도 (요구 351 · PLAN 537-1) — Slide2 · SLOPE/W Probabilistic 과 같은 생각. + +확률 변수 = 물성(c · φ · γ) · 지하수위(수위선 위아래 이동 m) · r_u — 정규 · 대수정규 · 절단(평균 아래 · 위 +거리 = Slide2 Relative Min · Max · 물리 한계 c ≥ 0 · 0 ≤ φ < 89 · γ > 0 · 0 ≤ r_u < 1 도 절단) · 변수 사이 상관 +(Slide2 c–φ Correlation · 표준정규에서 촐레스키) → 몬테카를로 · 라틴 하이퍼큐브 N 회 +→ 안전율 표본 → 평균 · 표준편차 · Pf = P(F < 1) · β 정규 (μ − 1)/σ · β 대수정규 +ln(μ/√(1+V²))/√ln(1+V²) · 히스토그램 · 누적분포 · P(F < 기준안전율). +갈래: fixed = 결정론 임계면 고정(절편 기하 한 번 · 물성만 바꿔 묶음 평가 · 빠름 · Slide2 Global Minimum) · +search = 표본마다 임계면 다시 찾음(`Engine_Search.analyze` · 느림 · 프로세스 병렬 · Slide2 Overall Slope). +민감도 = 변수 하나씩 범위(절단 끝 · 없으면 ±3σ) 50 등분 훑기(나머지 평균 · 임계면 고정 · Slide2 Sensitivity 와 같음) +· 토네이도 = 끝값 F 폭 순. +γ 변수 = γt 표본 · γsat 는 같은 차만큼 옮김(γsat − γt 그대로). +search 일꾼 = `python -m E03_StabSection.E03_StabSection_Engine_Prob `(서버 프로세스에서 풀을 열지 않음 · +`Worker` 머리글) · 한 줄마다 {"done": n} · 끝에 job["out"] 파일. +NumPy · SciPy(stats) 만 · 계산 자리 = ③ 서버 단독. +""" + +from __future__ import annotations + +import copy +import json +import math +import sys +import time +from pathlib import Path + +import numpy as np +from scipy.stats import norm + +from E03_StabSection import E03_StabSection_Engine_LEM as lem +from E03_StabSection.E03_StabSection_Engine_Search import analyze +from E03_StabSection.E03_StabSection_Engine_Station import MIN_DEPTH_M +from E03_StabSection.E03_StabSection_Engine_Surface import ( + circle_slices, + evaluate, + poly_slices, + three_point_center, +) + +PROPS = ("c", "phi", "gamma", "water", "ru") +#: 물리 한계(절단) — 지하수 이동은 한계 없음 +BOUNDS = {"c": (0.0, math.inf), "phi": (0.0, 89.0), "gamma": (1e-3, math.inf), "ru": (0.0, 0.99)} +#: 절단 없을 때 민감도 훑기 범위(±σ 배) +SWEEP_SIGMA = 3.0 +SWEEP_POINTS = 51 +HIST_BINS = 30 +CDF_POINTS = 101 +N_SLICES = 50 + + +# ---- 분포 · 표본 -------------------------------------------------------------- +def _bounds(v: dict) -> tuple[float, float]: + """절단 구간 [a, b] — 평균 ∓ 아래 · 위 거리 ∩ 물리 한계.""" + lo_phys, hi_phys = BOUNDS.get(v["prop"], (-math.inf, math.inf)) + mean = float(v["mean"]) + a = mean - float(v["lo"]) if v.get("lo") is not None else -math.inf + b = mean + float(v["hi"]) if v.get("hi") is not None else math.inf + return max(a, lo_phys), min(b, hi_phys) + + +def _lognormal(v: dict) -> tuple[float, float]: + """대수정규 (μ_ln, σ_ln) — 평균 · 표준편차 그대로 맞춤.""" + mean, std = float(v["mean"]), float(v["std"]) + s2 = math.log1p((std / mean) ** 2) + return math.log(mean) - 0.5 * s2, math.sqrt(s2) + + +def ppf(v: dict, u: np.ndarray) -> np.ndarray: + """균등 u(0~1) → 변수 값(절단 분포 역함수).""" + u = np.asarray(u, float) + mean, std = float(v["mean"]), float(v.get("std") or 0.0) + if std <= 0: + return np.full(u.shape, mean) + a, b = _bounds(v) + if v.get("dist") == "lognormal" and v["prop"] != "water": + if mean <= 0: + raise ValueError(f"대수정규는 평균 > 0: {v}") + mu, s = _lognormal(v) + za = (math.log(a) - mu) / s if a > 0 else -math.inf + zb = (math.log(b) - mu) / s if b < math.inf else math.inf + pa, pb = norm.cdf(za), norm.cdf(zb) + return np.exp(mu + s * norm.ppf(pa + u * (pb - pa))) + pa, pb = norm.cdf((a - mean) / std), norm.cdf((b - mean) / std) + return mean + std * norm.ppf(pa + u * (pb - pa)) + + +def sample( + variables: list[dict], + n: int, + *, + sampling: str = "lhs", + seed: int = 0, + correlations: list | None = None, +) -> np.ndarray: + """표본 (n, 변수 수) — lhs = 라틴 하이퍼큐브(변수마다 n 칸 하나씩 · 칸 차례 섞음) · mc = 단순 무작위. + + correlations = [(i, j, ρ), …] — 표준정규 칸에서 촐레스키로 섞은 뒤 분포로(LHS 층은 섞인 변수만 흐려짐).""" + rng = np.random.default_rng(int(seed)) + k = len(variables) + if sampling == "lhs": + order = np.argsort(rng.random((n, k)), axis=0) + u = (order + rng.random((n, k))) / n + else: + u = rng.random((n, k)) + u = np.clip(u, 1e-12, 1 - 1e-12) + if correlations: + corr = np.eye(k) + for i, j, rho in correlations: + corr[i, j] = corr[j, i] = float(rho) + u = norm.cdf(norm.ppf(u) @ np.linalg.cholesky(corr).T) + u = np.clip(u, 1e-12, 1 - 1e-12) + return np.column_stack([ppf(v, u[:, j]) for j, v in enumerate(variables)]) if k else u + + +def sweep_range(v: dict) -> tuple[float, float]: + """민감도 훑기 범위 — 절단 끝 · 없으면 평균 ±3σ(물리 한계 안).""" + mean, std = float(v["mean"]), float(v.get("std") or 0.0) + a, b = _bounds(v) + lo = a if math.isfinite(a) and v.get("lo") is not None else mean - SWEEP_SIGMA * std + hi = b if math.isfinite(b) and v.get("hi") is not None else mean + SWEEP_SIGMA * std + lo_phys, hi_phys = BOUNDS.get(v["prop"], (-math.inf, math.inf)) + return max(lo, lo_phys), min(hi, hi_phys) + + +# ---- 통계 ------------------------------------------------------------------- +def statistics(F: np.ndarray, required: float | None = None, bins: int = HIST_BINS) -> dict: + """안전율 표본 → 평균 · 표준편차 · Pf · β 정규 · 대수정규 · 히스토그램 · 누적분포(해 없는 표본 = 셈 밖).""" + F = np.asarray(F, float) + ok = F[np.isfinite(F)] + out = {"n": int(len(F)), "n_valid": int(len(ok))} + if len(ok) < 2: + return {**out, "mean": None, "std": None, "pf": None, "beta_normal": None} + mean, std = float(ok.mean()), float(ok.std(ddof=1)) + V = std / mean if mean > 0 else math.nan + beta_ln = ( + math.log(mean / math.sqrt(1 + V * V)) / math.sqrt(math.log1p(V * V)) + if mean > 0 and V > 0 + else None + ) + counts, edges = np.histogram(ok, bins=bins) + p = np.linspace(0.0, 1.0, CDF_POINTS) + out.update( + mean=mean, + std=std, + cov=V if math.isfinite(V) else None, + min=float(ok.min()), + max=float(ok.max()), + pf=float(np.mean(ok < 1.0)), + n_fail=int(np.sum(ok < 1.0)), + beta_normal=(mean - 1.0) / std if std > 0 else None, + beta_lognormal=beta_ln, + required=required, + p_below_required=float(np.mean(ok < required)) if required else None, + histogram={"edges": edges.tolist(), "counts": counts.tolist()}, + cdf={"p": p.tolist(), "F": np.quantile(ok, p).tolist()}, + ) + return out + + +# ---- 변수 → 모형 --------------------------------------------------------------- +def perturb(model: dict, variables: list[dict], row) -> dict: + """모형 사본 — 표본 한 줄을 물성 · 지하수위에 넣음(search 갈래).""" + out = dict(model) + mats = {m["id"]: dict(m) for m in model["materials"]} + dz = 0.0 + for v, x in zip(variables, row): + if v["prop"] == "water": + dz += float(x) + continue + if v["prop"] == "ru": + gw = model.get("groundwater") or {} + if gw.get("mode") == "ru": + out["groundwater"] = {**gw, "ru": float(x)} + continue + m = mats[v["material"]] + if v["prop"] == "gamma": + gt = float(m["gamma_t"]) + m["gamma_sat"] = float(m.get("gamma_sat", gt)) + float(x) - gt + m["gamma_t"] = float(x) + else: + m[v["prop"]] = float(x) + out["materials"] = list(mats.values()) + gw = out.get("groundwater") or {} + if dz and gw.get("mode") == "line": + out["groundwater"] = {**gw, "line": [[x, z + dz] for x, z in gw["line"]]} + return out + + +class Fixed: + """임계면 고정 — 모형 · 면 하나를 한 번 준비해 표본마다 물성 칸만 갈아 끼우고 절편 묶음으로 평가.""" + + def __init__(self, model: dict, surface: dict, direction: int, n_slices: int = N_SLICES): + self.prep = lem.Prepared(model, direction) + self.n = n_slices + s = self.prep.direction + self.index = {m["id"]: i for i, m in enumerate(self.prep.materials)} + if surface["kind"] == "circle": + xs = sorted((s * surface["entry"][0], s * surface["exit"][0])) + self.geom = ( + "circle", + s * surface["center"][0], + surface["center"][1], + surface["radius"], + ) + self.geom += tuple(xs) + else: + pts = sorted((s * x, z) for x, z in surface["points"]) + self.geom = ("polyline", np.array([p[0] for p in pts]), np.array([p[1] for p in pts])) + + def _prep(self, variables: list[dict], row) -> lem.Prepared: + base = self.prep + p = copy.copy(base) + gt, gs = base.gamma_t.copy(), base.gamma_sat.copy() + c, tp = base.c.copy(), base.tanphi.copy() + dz = 0.0 + for v, x in zip(variables, row): + if v["prop"] == "water": + dz += float(x) + continue + if v["prop"] == "ru": + if base.gw_mode == "ru": + p.ru = float(x) + continue + i = self.index[v["material"]] + if v["prop"] == "c": + c[i] = x + elif v["prop"] == "phi": + tp[i] = math.tan(math.radians(x)) + else: + gs[i] += x - gt[i] + gt[i] = x + p.gamma_t, p.gamma_sat, p.c, p.tanphi = gt, gs, c, tp + p.table = base.table.copy() + p.table[:, 4, :] = gt[base.mat] + p.table[:, 5, :] = gs[base.mat] - gt[base.mat] + if dz and base.gw_mode == "line": + p.water_z = base.water_z + dz + return p + + def _slices(self, prep: lem.Prepared) -> tuple[dict, np.ndarray]: + g = self.geom + if g[0] == "circle": + _, xc, zc, R, xE, xX = g + sl = circle_slices(prep, [xc], [zc], [R], [xE], [xX], self.n) + return sl, np.array([[xc, zc]]) + vx, vz = g[1][None, :], g[2][None, :] + return poly_slices(prep, vx, vz, self.n), three_point_center(vx, vz) + + def F(self, variables: list[dict], X: np.ndarray, method: str) -> np.ndarray: + """표본 줄마다 안전율(해 없음 = nan).""" + X = np.atleast_2d(np.asarray(X, float)) + parts = [self._slices(self._prep(variables, row)) for row in X] + sl = _stack([p[0] for p in parts]) + center = np.vstack([p[1] for p in parts]) + mp_seed = None + if method == "morgenstern_price": # describe 와 같은 출발 참고값(같은 면 Spencer) + mp_seed = evaluate(sl, center, ("spencer",))["spencer"]["F"] + sl.pop("_pre", None) + F = evaluate(sl, center, (method,), mp_seed=mp_seed)[method]["F"] + return np.where(sl["valid"], F, np.nan) + + +def _stack(parts: list[dict]) -> dict: + """한 줄짜리 절편 묶음들 → (S, …) 하나(배열 칸만 잇고 스칼라는 그대로).""" + out = {} + for key, value in parts[0].items(): + if key == "_pre": + continue + if isinstance(value, np.ndarray) and value.ndim >= 1 and value.shape[0] == 1: + out[key] = np.concatenate([p[key] for p in parts]) + else: + out[key] = value + return out + + +def _chunks(n: int, size: int): + for i in range(0, n, size): + yield slice(i, min(i + size, n)) + + +def sensitivity( + fixed: Fixed, variables: list[dict], method: str, points: int = SWEEP_POINTS +) -> dict: + """변수 하나씩 범위 훑기(나머지 평균) · 토네이도 = 끝값 F 폭 큰 순.""" + means = np.array([float(v["mean"]) for v in variables]) + rows, spans = [], [] + for j, v in enumerate(variables): + lo, hi = sweep_range(v) + xs = np.linspace(lo, hi, points) if hi > lo else np.full(points, means[j]) + block = np.tile(means, (points, 1)) + block[:, j] = xs + rows.append(block) + spans.append(xs) + F0 = float(fixed.F(variables, means[None, :], method)[0]) + if not rows: + return {"F_mean": F0, "curves": [], "tornado": []} + F = fixed.F(variables, np.vstack(rows), method).reshape(len(variables), points) + num = lambda a: [float(x) if np.isfinite(x) else None for x in a] # noqa: E731 + curves = [] + for v, xs, f in zip(variables, spans, F): + ends = [f[0], f[-1]] + swing = abs(ends[1] - ends[0]) if all(np.isfinite(ends)) else None + curves.append( + { + "name": v["name"], + "x": num(xs), + "F": num(f), + "F_lo": num([ends[0]])[0], + "F_hi": num([ends[1]])[0], + "swing": swing, + } + ) + tornado = sorted( + (c for c in curves if c["swing"] is not None), key=lambda c: c["swing"], reverse=True + ) + return {"F_mean": F0 if math.isfinite(F0) else None, "curves": curves, "tornado": [ + {"name": c["name"], "F_lo": c["F_lo"], "F_hi": c["F_hi"], "swing": c["swing"]} + for c in tornado + ]} # fmt: skip + + +# ---- 매회 탐색(search) ------------------------------------------------------------ +_CTX: dict = {} + + +def _init(ctx: dict) -> None: + _CTX.clear() + _CTX.update(ctx) + + +def _search_one(row) -> float: + c = _CTX + m = c["method"] + methods = tuple(dict.fromkeys(("bishop", m))) + res = analyze( + perturb(c["model"], c["variables"], row), + direction=c["direction"], + limits=c["limits"], + methods=methods, + noncircular=m not in ("ordinary", "bishop"), # 비원호 Fellenius · Bishop = 참고 칸 + min_depth=MIN_DEPTH_M, + ) + best = res["methods"][m]["min"] + return float(best["F"]) if best and best.get("F") is not None else math.nan + + +def search_F(ctx: dict, X: np.ndarray, workers: int = 1, emit=None) -> np.ndarray: + """표본마다 임계면 다시 찾음 — workers > 1 = 프로세스 풀(서버 밖 일꾼에서만).""" + out = np.full(len(X), np.nan) + if workers <= 1: + _init(ctx) + for i, row in enumerate(X): + out[i] = _search_one(row) + emit and emit(i + 1) + return out + from concurrent.futures import ProcessPoolExecutor, as_completed + + with ProcessPoolExecutor(max_workers=workers, initializer=_init, initargs=(ctx,)) as pool: + futures = {pool.submit(_search_one, row): i for i, row in enumerate(X)} + for done, future in enumerate(as_completed(futures), 1): + out[futures[future]] = future.result() + emit and emit(done) + return out + + +# ---- 한 번 해석 --------------------------------------------------------------- +def run( + model: dict, + *, + surface: dict, + direction: int, + limits=None, + variables: list[dict], + correlations: list | None = None, + n: int = 1000, + sampling: str = "lhs", + seed: int = 0, + mode: str = "fixed", + method: str = "bishop", + required: float | None = None, + workers: int = 1, + emit=None, +) -> dict: + """확률 해석 + 민감도 한 벌. emit(끝난 표본 수) = 진행률.""" + t0 = time.perf_counter() + X = sample(variables, n, sampling=sampling, seed=seed, correlations=correlations) + fixed = Fixed(model, surface, direction) + t1 = time.perf_counter() + if mode == "search": + ctx = { + "model": model, + "variables": variables, + "direction": direction, + "limits": limits, + "method": method, + } + F = search_F(ctx, X, workers, emit) + else: + F = np.full(n, np.nan) + for part in _chunks(n, 250): + F[part] = fixed.F(variables, X[part], method) + emit and emit(part.stop) + t2 = time.perf_counter() + sens = sensitivity(fixed, variables, method) + t3 = time.perf_counter() + realized = [ + {"name": v["name"], "mean": float(X[:, j].mean()), "std": float(X[:, j].std(ddof=1))} + for j, v in enumerate(variables) + ] + return { + "mode": mode, + "method": method, + "n": n, + "sampling": sampling, + "seed": seed, + "variables": variables, + "correlations": correlations or [], + "realized": realized, + "stats": statistics(F, required), + "F": [round(float(f), 4) if np.isfinite(f) else None for f in F], + "sensitivity": sens, + "elapsed_s": { + "prepare": round(t1 - t0, 3), + "samples": round(t2 - t1, 3), + "sensitivity": round(t3 - t2, 3), + "total": round(t3 - t0, 3), + }, + } + + +def main() -> None: + """일꾼 — job = run() 인자 + out(결과 파일) · 한 줄마다 진행.""" + path = Path(sys.argv[1]) + job = json.loads(path.read_text(encoding="utf-8")) + out = Path(job.pop("out")) + result = run(**job, emit=lambda k: print(json.dumps({"done": k}), flush=True)) + out.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/E03_StabSection/E03_StabSection_Engine_Search.py b/E03_StabSection/E03_StabSection_Engine_Search.py new file mode 100644 index 000000000..a5ba221c4 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Search.py @@ -0,0 +1,628 @@ +"""E03 임계 활동면 탐색 — 원호(Grid & Radius · Entry & Exit · 다듬기) · 비원호(블록 + 꼭짓점 최적화). + +활동 방향 +x 로 맞춘 `Prepared` 위에서 찾고 좌표를 원래 방향으로 되돌려 냄. +다듬기 · 비원호 = 차분진화(best/1/bin · 무리 전체를 한 번에 평가 · seed 고정 → 같은 입력 같은 답) ++ 비원호 마지막 국부 다듬기 = 묶음 Nelder-Mead(후보 넷을 한 번에). SciPy 최적화는 점마다 파이썬 고리라 느려 +직접 묶음 꼴로 씀. 방법마다 찾은 면을 모든 방법으로 다시 셈(교차) → 한 방법 탐색이 놓친 골짜기를 메움. +상용 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 E03_StabSection import E03_StabSection_Engine_LEM as lem +from E03_StabSection.E03_StabSection_Engine_Describe import describe, safety_map +from E03_StabSection.E03_StabSection_Engine_Surface import ( # noqa: F401 — 밖에서 Search 로 부르던 이름 + METHODS, + circle_cross_surface, + circle_from_chord, + circle_slices, + evaluate, + objective, + poly_base, + poly_slices, + three_point_center, +) + +_BIG = 1e3 +# 탐색 예산(실험 손잡이) +LS_ITER = 30 +LS_ITER_SPENCER = 60 # Spencer 비원호는 풀리는 면이 조각나 더 걸음 +POLISH = "nm" +NM_ITER = 120 +PATTERN_ITER = 30 +DE_MAXITER = 60 +DE_PATIENCE = 12 +SPENCER_SEED_POOL = 300 +#: 교차 재평가에 넣는 방법별 마지막 무리 상위 원 수 +CROSS_TOP = 10 +#: Spencer 원호 씨앗 — Bishop 임계 둘레 구름 크기 +CLOUD_SIZE = 200 +#: 엄밀해 「해 없음」 허용 — Bishop 임계 원에서 해 없어도 찾은 최소가 Bishop × (1 + 이 값) 안이면 값을 살림 +FAR_TOL = 0.02 + + +# ---- 원호 --------------------------------------------------------------------- +def entry_exit_circles(prep, limits, n_points=25, thetas_deg=(10, 20, 35, 50, 65, 80, 100, 120)): + xl, xr = limits[:2] + 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[:2] + 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=None): + """원호 다듬기 — (진입 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 + + init = _population(seeds, lo, hi, 30, seed) + x, fmin, pop, f = _de(obj, lo, hi, init, maxiter or DE_MAXITER, seed, patience=DE_PATIENCE) + top = pop[np.argsort(f)[:CROSS_TOP]] + top = top[obj(top) < _BIG] + xc, zc, R, _ = circle_from_chord(prep, top[:, 0], top[:, 1], top[:, 2]) + near = [ + (float(a), float(b), float(c), float(d), float(e)) + for a, b, c, d, e in zip(xc, zc, R, top[:, 0], top[:, 1]) + ] + xc, zc, R, _ = circle_from_chord(prep, x[0:1], x[1:2], x[2:3]) + best = (float(xc[0]), float(zc[0]), float(R[0]), float(x[0]), float(x[1])) + return float(fmin), best, near, top + + +def _cloud(prep, limits, method, p0, n, min_depth, seed, k=CLOUD_SIZE, keep=10): + """원 (진입 x, 진출 x, 중심각) 둘레 구름 k 개를 method 로 한 번에 셈 → 풀리는 좋은 것 keep 개.""" + rng = np.random.default_rng(seed) + span = limits[1] - limits[0] + lo = np.array([limits[0], limits[0], np.radians(3)]) + hi = np.array([limits[1], limits[1], np.radians(170)]) + p = np.clip(p0 + rng.normal(size=(k, 3)) * [0.03 * span, 0.03 * span, np.radians(4)], lo, hi) + xc, zc, R, ok = circle_from_chord(prep, p[:, 0], p[:, 1], p[:, 2]) + F = np.full(k, np.inf) + if ok.any(): + sl = circle_slices(prep, xc[ok], zc[ok], R[ok], p[ok, 0], p[ok, 1], n, min_depth) + f = evaluate(sl, np.column_stack([xc[ok], zc[ok]]), (method,), scan=False)[method]["F"] + F[ok] = np.where(np.isfinite(f), f, np.inf) + order = np.argsort(F)[:keep] + return p[order[np.isfinite(F[order])]].reshape(-1, 3) + + +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 _nelder_mead(obj, x0, lo, hi, maxiter, xatol=1e-4, fatol=1e-6): + """묶음 Nelder-Mead — 반사 · 확장 · 바깥 · 안쪽 수축 넷을 한 번에 셈(판단은 표준 NM 그대로 · 줄어듦도 한 번에). + + SciPy NM 은 점마다 따로 불러 한 면 계산의 파이썬 부담이 큼 — 부르는 수를 줄임. 첫 꼭짓점 = SciPy 와 같은 5 % 흔들기. + """ + D = len(x0) + sim = np.tile(np.asarray(x0, float), (D + 1, 1)) + for k in range(D): + sim[k + 1, k] = sim[k + 1, k] * 1.05 if sim[k + 1, k] != 0 else 0.00025 + sim = np.clip(sim, lo, hi) + fs = obj(sim) + for _ in range(maxiter): + order = np.argsort(fs, kind="stable") + sim, fs = sim[order], fs[order] + if np.max(np.abs(sim[1:] - sim[0])) <= xatol and np.max(np.abs(fs[1:] - fs[0])) <= fatol: + break + xbar = sim[:-1].mean(0) + xw = sim[-1] + cand = np.clip( + np.vstack( + [2 * xbar - xw, 3 * xbar - 2 * xw, 1.5 * xbar - 0.5 * xw, 0.5 * xbar + 0.5 * xw] + ), + lo, + hi, + ) + fr, fe, fc, fcc = obj(cand) + if fr < fs[0]: + sim[-1], fs[-1] = (cand[1], fe) if fe < fr else (cand[0], fr) + elif fr < fs[-2]: + sim[-1], fs[-1] = cand[0], fr + elif fr < fs[-1] and fc <= fr: + sim[-1], fs[-1] = cand[2], fc + elif fr >= fs[-1] and fcc < fs[-1]: + sim[-1], fs[-1] = cand[3], fcc + else: + sim[1:] = np.clip(sim[0] + 0.5 * (sim[1:] - sim[0]), lo, hi) + fs[1:] = obj(sim[1:]) + k = int(np.argmin(fs)) + return sim[k].copy(), float(fs[k]) + + +def _pattern(obj, x, fx, lo, hi, step, iters): + """묶음 좌표 패턴 탐색 — 축마다 ±걸음 2D 점을 한 번에 셈 · 나아지면 옮기고 그 쪽으로 한 번 더 · 아니면 걸음 ½.""" + h = step.copy() + for _ in range(iters): + cand = np.clip(np.vstack([x + np.diag(h), x - np.diag(h)]), lo, hi) + f = obj(cand) + k = int(np.argmin(f)) + if f[k] < fx - 1e-9: + move = cand[k] - x + x2 = np.clip(cand[k] + move, lo, hi) + f2 = float(obj(x2[None, :])[0]) + x, fx = (x2, f2) if f2 < f[k] else (cand[k], float(f[k])) + else: + h *= 0.5 + if np.all(h < step * 1e-3): + break + return x, fx + + +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=None, polish=True +): + """비원호 꼭짓점 최적화 — 차분진화 뒤 가장 좋은 면을 Nelder-Mead 로 국부 다듬기(상용 SAO · Optimize 대응).""" + fun = objective(method) + xl, xr = limits[:2] + 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 or LS_ITER, seed + ) + if polish and fmin < _BIG and POLISH == "pattern": + x, fmin = _pattern(obj, x, fmin, lo, hi, step * 0.1, PATTERN_ITER) + elif polish and fmin < _BIG and POLISH == "nm": + xn, fn = _nelder_mead(obj, x, lo, hi, NM_ITER) + if fn < fmin: + x, fmin = xn, fn + 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[:2] + 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 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])))) + # 보강이 있어도 탐색 한계 그대로 — 보강 없는 해석과 같은 면 무리에서 찾아야 보강 전후가 맞섬 + # (한계를 넓히면 보강 없이 안 본 자연 비탈 면이 들어와 보강 뒤 F 가 오히려 낮아 보임) + results: dict = {m: {} for m in methods} + counts = {} + contour = smap = 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 not in ("spencer", "morgenstern_price")) + cheap = cheap 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)) + ], + } + smap = safety_map(prep, coarse, F[key], key) + if "spencer" in methods: + # Spencer 씨앗 — 거친 탐색 상위 원에서 Spencer 를 따로 셈(풀리는 원이 조각나 있어 Bishop 순위만으론 놓침) + top = np.argsort(np.where(np.isfinite(F[key]), F[key], np.inf))[:SPENCER_SEED_POOL] + top = top[np.isfinite(F[key][top])] + F["spencer"] = np.full(len(coarse[0]), np.nan) + if len(top): + c5 = [v[top] for v in coarse[:5]] + sl = circle_slices(prep, *c5, coarse_slices, min_depth) + center = np.column_stack([c5[0], c5[1]]) + F["spencer"][top] = evaluate(sl, center, ("spencer",), scan=False)["spencer"]["F"] + searched = [m for m in methods if m != "morgenstern_price"] + pool, tops = [], {} + for m in searched: + 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)]) + if m == "spencer" and "bishop" in tops: + # Spencer 가 풀리는 원은 Bishop 임계 둘레에 좁게 · 조각나 있음 — 그 둘레 구름에서 풀리는 원을 씨앗에 보탬 + cloud = _cloud(prep, lim, m, tops["bishop"][0], n_slices, min_depth, seed) + seeds = np.vstack([seeds[:20], cloud, tops["bishop"]]) + fmin, geom, near, top = refine_circle(prep, lim, m, seeds, n_slices, min_depth, seed) + if fmin < _BIG: + best_circles[m] = geom + pool += [geom, *near] + tops[m] = top + # 방법마다 찾은 원(+ 마지막 무리 상위)을 모든 방법으로 다시 셈 → 방법별 가장 낮은 면 + # (한 방법 탐색이 놓친 골짜기 메움 · Spencer 는 풀리는 원이 조각나 있어 특히 필요) + for m in methods: + # M-P(차례 계산 · 느림)는 다른 방법이 고른 원에서만 + geoms = list(best_circles.values()) if m == "morgenstern_price" else pool + g = _cross(prep, "circle", geoms, m, n_slices, min_depth) + if g is not None: + best_circles[m] = g + results[m]["circular"] = _describe(prep, "circle", g, m, n_slices, min_depth) + # 엄밀해(Spencer · M-P)가 Bishop 임계 원에서 해 없음 · 찾은 최소가 Bishop + FAR_TOL 넘게 높음 = 임계 + # 둘레에서 풀리는 원이 없어 먼 원을 고른 것 → 그 방법 원호 = 「해 없음」(Bishop 임계 원 · F None · 까닭 · + # F–λ 곡선) · 허용 안이면 찾은 값을 살림(status converged_far) + ref = best_circles.get("bishop") if "bishop" in results else None + fb = (results.get("bishop", {}).get("circular") or {}).get("F") + for m in [k for k in ("spencer", "morgenstern_price") if k in methods and ref and fb]: + cur = results[m].get("circular") + if cur and cur["F"] is not None and cur["F"] <= fb: + continue + at = _describe(prep, "circle", ref, m, n_slices, min_depth) + if at["F"] is not None: + continue + if cur and cur["F"] is not None and cur["F"] <= fb * (1 + FAR_TOL): + cur["status"] = "converged_far" + cur["reason"] = ( + f"Bishop 임계 원에서 해 없음 · 다른 원 최소가 Bishop 의 +{FAR_TOL:.0%} 안이라 살림" + ) + else: + results[m]["circular"] = at + 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) · M-P 는 찾은 면에서 값만 + found = [] + for m in [k for k in ("janbu", "spencer") if k in methods]: + iters = LS_ITER_SPENCER if m == "spencer" else LS_ITER + fmin, _x, geom, top = optimize_polyline( + prep, lim, m, seeds, n_slices, min_depth, mv, seed, iters + ) + seeds = [*top, *seeds] + if fmin < _BIG: + found.append(geom) + for m in methods: + g = _cross(prep, "polyline", found, m, n_slices, min_depth) + if g is not None: + results[m]["noncircular"] = _describe(prep, "polyline", g, m, n_slices, min_depth) + for m in methods: + # 비원호 면 위 Fellenius · Bishop = 모멘트 중심 임의 → 참고 칸(최소 F 후보에서 뺌 · Slide2 와 같음) + if m in ("ordinary", "bishop") and results[m].get("noncircular"): + results[m]["noncircular"]["reference_only"] = True + cands = [ + v + for k, v in results[m].items() + if v.get("F") is not None and not v.get("reference_only") + ] + results[m]["min"] = min(cands, key=lambda v: v["F"]) if cands else None + out = { + "direction": s, + "limits": sorted([s * lim[0], s * lim[1]]), + "methods": results, + "contour": contour, + "safety_map": smap, + "counts": counts, + "elapsed_s": round(time.perf_counter() - t0, 3), + } + if prep.supports is not None: + # 보강력 결과 = 판정 임계면(방법별 최소 중 가장 낮은 면) 기준 + mins = [(m, v["min"]) for m, v in results.items() if v.get("min")] + if mins: + m, gov = min(mins, key=lambda kv: kv[1]["F"]) + out["support_forces"] = gov.get("support_forces", []) + out["support_forces_method"] = m + else: + out["support_forces"], out["support_forces_method"] = [], None + return out + + +def _stack(prep, kind, geoms, n, min_depth): + if kind == "circle": + xc, zc, R, xE, xX = (np.array([g[k] for g in geoms], float) for k in range(5)) + return circle_slices(prep, xc, zc, R, xE, xX, n, min_depth), np.column_stack([xc, zc]) + vx = np.array([g[0] for g in geoms], float) + vz = np.array([g[1] for g in geoms], float) + return poly_slices(prep, vx, vz, n, min_depth), three_point_center(vx, vz) + + +def _cross(prep, kind, geoms, method, n, min_depth): + """찾은 면들 위 method 안전율을 한 번에 → 가장 낮은 면(없으면 None).""" + if not geoms: + return None + sl, center = _stack(prep, kind, geoms, n, min_depth) + F = evaluate(sl, center, (method,))[method]["F"] + if not np.isfinite(F).any(): + return None + return geoms[int(np.nanargmin(F))] + + +def _describe(prep, kind, geom, method, n, min_depth): + """M-P 는 같은 면 Spencer 값을 출발 참고값으로(엉뚱한 근 거름).""" + seed = None + if method == "morgenstern_price": + sl, center = _stack(prep, kind, [geom], n, min_depth) + sp = evaluate(sl, center, ("spencer",))["spencer"]["F"][0] + seed = float(sp) if np.isfinite(sp) else None + return describe(prep, kind, geom, method, n, min_depth, seed) + + +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..2d4a0c3b9 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Station.py @@ -0,0 +1,118 @@ +"""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 +from E03_StabSection.E03_StabSection_Types import groundwater as norm_groundwater + +#: 활동면 최소 깊이(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, + case_workers: int = 1, + groundwater_for=None, + supports=None, + **model_kw, +) -> dict: + """측점 하나 — case_workers > 1 이면 비탈 × 조건 경우를 프로세스로 나눠 돎(측점 하나만 다시 셀 때). + + groundwater_for(condition, model) → 엔진 지하수 꼴 — 주면 조건마다 모형 지하수를 갈아 끼움 + (E02 `station_groundwater` 꼴) · None 이면 모형 기본(우기 = 지표 수위). + supports = 네일 · 앵커(`Engine_Support` 꼴) — 보강재마다 "side"(없으면 머리 x 가 있는 쪽 = 노면 가운데 + 기준 좌 +)의 비탈 경우에만 넣음 · 결과 cases[i].result.support_forces · None 이면 지금 그대로. + + 서버 프로세스 안에서는 1(차례로) 그대로 둠 — 자식이 main.py 를 다시 읽음(`Worker` 머리글). + """ + t0 = time.perf_counter() + out = {"chainage_m": cross.get("chainage_m"), "label": cross.get("label"), "cases": []} + jobs = [] + 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, + ) + if groundwater_for is not None: + model["groundwater"] = norm_groundwater(groundwater_for(cond, model)) + out.setdefault("areas", model["areas"]) + for side in model["sides"]: + own = _side_supports(supports, side["side"], design) + jobs.append((cond, {**model, "supports": own} if own else model, side)) + kw = {"methods": methods, "noncircular": noncircular, "min_depth": min_depth} + if case_workers > 1 and len(jobs) > 1: + from concurrent.futures import ProcessPoolExecutor + + with ProcessPoolExecutor(max_workers=min(case_workers, len(jobs))) as pool: + futures = [ + pool.submit(analyze, m, direction=sd["direction"], limits=sd["limits"], **kw) + for _, m, sd in jobs + ] + results = [f.result() for f in futures] + else: + results = [ + analyze(m, direction=sd["direction"], limits=sd["limits"], **kw) for _, m, sd in jobs + ] + for (cond, _m, side), res in zip(jobs, results): + mins = {m: (res["methods"][m]["min"] or {}).get("F") for m in methods} + if supports: + res.setdefault("support_forces", []) # 보강재 없는 쪽 비탈 = 빈 칸 + out["cases"].append({"condition": cond, **side, "min_F": mins, "result": res}) + out["elapsed_s"] = round(time.perf_counter() - t0, 3) + return out + + +def _side_supports(supports, side: str, design: dict) -> list[dict]: + """그 비탈 보강재 — "side" 칸 · 없으면 머리 x 가 노면 가운데(좌 · 우 노면 끝 가운데)보다 + 면 좌.""" + if not supports: + return [] + edges = design.get("road_edges") or {} + mid = 0.5 * ( + float((edges.get("left") or {}).get("offset_m", 2.0)) + + float((edges.get("right") or {}).get("offset_m", -2.0)) + ) + return [ + s + for s in supports + if (s.get("side") or ("left" if float(s["x"]) >= mid else "right")) == side + ] + + +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/E03_StabSection/E03_StabSection_Engine_Support.py b/E03_StabSection/E03_StabSection_Engine_Support.py new file mode 100644 index 000000000..4da53b2fa --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Support.py @@ -0,0 +1,152 @@ +"""E03 보강재(네일 · 앵커) — 활동면을 지나는 보강력을 절편 바닥 점하중으로(Slide2 Support 와 같은 꼴). + +입력 꼴(E03 보강 검토 화면과 한 벌) supports = [{ + "type": "nail" | "anchor", "x", "z" (머리 · 모형 좌표 m), "angle_deg" (수평 아래로 +), "length_m", + "spacing_m" (종방향 간격 · 단위 폭 힘 = 힘 / 간격), "tensile_kN" (강재 인장 한도), + "bond_kPa" (그라우트 · 지반 부착 · 네일 = 전 길이 · 앵커 = 끝 정착장만), "bond_length_m" (앵커 정착장), + "hole_dia_mm", "prestress_kN" (앵커만 · 0 이면 수동), "plate_kN"? (머리 판 · 없으면 tensile_kN), + "side"? ("left" | "right" · 고르는 것은 `Engine_Station`)}] +힘 크기 = 지점마다 Slide2 Force Diagram 의 최소 — 인장(tensile) · 인발(pullout = 부착 × 활동면 밖 부착 길이) · + 벗겨짐(stripping = 판 + 부착 × 활동면 안 부착 길이) · 부착 = π·D·bond_kPa (kN/m) · 자유장은 부착 없음. + 인발 안전율은 입력 부착값에 넣어 둠(Slide2 와 같음 — 엔진은 나누지 않음). +방향 = 보강재와 나란히(Slide2 Parallel to Reinforcement) · 비탈 속으로(활동 방향 반대 = −x) 아래로 angle. +능동 · 수동 = Slide2 Active/Passive Force Application: + 능동(프리스트레스 앵커) = 힘 그대로 — 활동력을 줄임 F = R / (D − T) · 힘 = min(프리스트레스, 한도) + 수동(네일 · 프리스트레스 0 앵커) = 힘 / F — 저항을 늘림 F = (R + T) / D +머리가 활동 덩이 밖이면(활동면이 보강재를 안 지나거나 덩이 밖에서 드나듦) 0 = not_crossing. +절편 칸(꼴 (S, n) · 활동 방향 +x 틀): sup_va · sup_ha = 능동 연직(아래 +) · 수평(+x) · sup_vp · sup_hp = 수동(× 1/F 전) + · sup_xva · sup_zha · sup_xvp · sup_zhp = 모멘트용 x·연직 · z·수평. 보강재 칸(꼴 (S, K)) sup_T · sup_mode · sup_x · sup_z. +""" + +import numpy as np + +MODES = ("tensile", "pullout", "stripping", "not_crossing", "prestress") +_NOT, _PRE = 3, 4 +_FIELDS = ("va", "ha", "vp", "hp", "xva", "zha", "xvp", "zhp") + + +def prepare(raw: list[dict] | None, direction: int) -> dict | None: + """입력 꼴 → 배열(활동 방향 +x 틀 · x 에 direction 곱함) · 없으면 None.""" + if not raw: + return None + s = 1.0 if direction >= 0 else -1.0 + cols = {k: [] for k in ("hx", "hz", "dx", "dz", "L", "free", "q", "tens", "plate", "sp", "pre")} + active = [] + for r in raw: + kind = r.get("type", "nail") + if kind not in ("nail", "anchor"): + raise ValueError(f"보강재 종류 모름: {kind}") + L, sp = float(r["length_m"]), float(r["spacing_m"]) + tens, bond = float(r["tensile_kN"]), float(r["bond_kPa"]) + dia = float(r["hole_dia_mm"]) / 1000.0 + lb = float(r.get("bond_length_m") or 0.0) if kind == "anchor" else L + pre = float(r.get("prestress_kN") or 0.0) if kind == "anchor" else 0.0 + plate = float(r["plate_kN"]) if r.get("plate_kN") is not None else tens + if L <= 0 or sp <= 0 or dia <= 0 or tens < 0 or bond < 0 or plate < 0 or pre < 0: + raise ValueError(f"보강재 값 범위 밖: {r}") + if not 0 < lb <= L: + raise ValueError(f"앵커 정착장은 0 초과 · 길이 이하: {r}") + a = np.radians(float(r["angle_deg"])) + for k, v in ( + ("hx", s * float(r["x"])), + ("hz", float(r["z"])), + ("dx", -np.cos(a)), + ("dz", -np.sin(a)), + ("L", L), + ("free", L - lb), + ("q", np.pi * dia * bond), + ("tens", tens), + ("plate", plate), + ("sp", sp), + ("pre", pre), + ): + cols[k].append(v) + active.append(pre > 0) + out = {k: np.array(v, float) for k, v in cols.items()} + out["active"] = np.array(active, bool) + return out + + +def tips(sup: dict) -> np.ndarray: + """보강재 끝 x (활동 방향 +x 틀) — 탐색 한계를 끝 뒤까지 넓힘.""" + return sup["hx"] + sup["L"] * sup["dx"] + + +def apply(sl: dict, sup: dict) -> None: + """절편 묶음(sl)에 보강력 칸을 덧붙임 — 면마다 보강재마다 첫 교점 · 힘 · 파괴 꼴.""" + xe, ze = sl["xe"], sl["ze"] + S, n = sl["b"].shape + hx, hz, dx, dz = (sup[k][None, :, None] for k in ("hx", "hz", "dx", "dz")) + ax, az = xe[:, None, :-1], ze[:, None, :-1] + ex, ez = np.diff(xe, axis=1)[:, None, :], np.diff(ze, axis=1)[:, None, :] + den = dx * ez - dz * ex + safe = np.where(np.abs(den) < 1e-12, np.nan, den) + wx, wz = ax - hx, az - hz + t = (wx * ez - wz * ex) / safe # 보강재 위 머리에서 거리 + u = (wx * dz - wz * dx) / safe # 활동면 마디 위 비율 + hit = (u >= 0) & (u < 1) & (t > 0) & (t <= sup["L"][None, :, None]) + tt = np.where(hit, t, np.inf) + j = np.argmin(tt, axis=-1) # (S, K) 첫 교점 절편 + tc = np.take_along_axis(tt, j[..., None], -1)[..., 0] + # 머리가 활동 덩이 안(진입 ~ 진출 사이 · 활동면 위)일 때만 — 첫 교점 = 덩이에서 나가는 점 + k_head = np.clip((xe[:, None, :] <= sup["hx"][None, :, None]).sum(-1) - 1, 0, n - 1) + x0 = np.take_along_axis(xe, k_head, 1) + x1 = np.take_along_axis(xe, k_head + 1, 1) + z0 = np.take_along_axis(ze, k_head, 1) + z1 = np.take_along_axis(ze, k_head + 1, 1) + zslip = z0 + (sup["hx"][None, :] - x0) * (z1 - z0) / np.maximum(x1 - x0, 1e-12) + inside = (sup["hx"] >= xe[:, :1]) & (sup["hx"] <= xe[:, -1:]) & (sup["hz"] > zslip) + cross = inside & np.isfinite(tc) & sl["valid"][:, None] + tc = np.where(cross, tc, 0.0) + lb = sup["L"] - sup["free"] + bond_in = np.clip(tc - sup["free"], 0.0, lb) + caps = np.stack( + [ + np.broadcast_to(sup["tens"], tc.shape), + sup["q"] * (lb - bond_in), + sup["plate"] + sup["q"] * bond_in, + ], + axis=-1, + ) + mode = np.argmin(caps, axis=-1) + T = np.min(caps, axis=-1) + pre_rules = sup["active"] & (sup["pre"] < T) + T = np.where(pre_rules, sup["pre"], T) + mode = np.where(pre_rules, _PRE, mode) + T = np.where(cross, T, 0.0) / sup["sp"] + mode = np.where(cross, mode, _NOT) + xs = sup["hx"] + tc * sup["dx"] + zs = sup["hz"] + tc * sup["dz"] + acc = {f: np.zeros((S, n)) for f in _FIELDS} + rows = np.arange(S) + for k in range(len(sup["L"])): + v, h = -T[:, k] * sup["dz"][k], T[:, k] * sup["dx"][k] # 아래 + · +x(비탈 속 = −) + tag = "a" if sup["active"][k] else "p" + for f, val in ((f"v{tag}", v), (f"h{tag}", h), (f"xv{tag}", xs[:, k] * v)): + np.add.at(acc[f], (rows, j[:, k]), val) + np.add.at(acc[f"zh{tag}"], (rows, j[:, k]), zs[:, k] * h) + for f in _FIELDS: + sl[f"sup_{f}"] = acc[f] + sl["sup_T"], sl["sup_mode"], sl["sup_x"], sl["sup_z"] = T, mode, xs, zs + + +def forces(sl: dict, k: int, F: float, active: np.ndarray, direction: int) -> list[dict]: + """면 k 의 보강재별 결과 칸(support_forces) — 한도(단위 폭) · 실제 넣은 힘(수동 = / F) · 파괴 꼴 · 교점.""" + out = [] + for i, (T, m) in enumerate(zip(sl["sup_T"][k], sl["sup_mode"][k])): + crossing = int(m) != _NOT + out.append( + { + "index": i, + "mobilized_kN_per_m": float(T), + "applied_kN_per_m": float(T if active[i] else T / F), + "mode": MODES[int(m)], + "force_application": "active" if active[i] else "passive", + "point": ( + [float(direction * sl["sup_x"][k, i]), float(sl["sup_z"][k, i])] + if crossing + else None + ), + } + ) + return out diff --git a/E03_StabSection/E03_StabSection_Engine_Surface.py b/E03_StabSection/E03_StabSection_Engine_Surface.py new file mode 100644 index 000000000..ff78a96d1 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Engine_Surface.py @@ -0,0 +1,185 @@ +"""E03 활동면 → 절편 · 방법 묶음 평가 — 원호(중심 · 반지름) · 꺾은선(꼭짓점) · 인장균열 · 모멘트 중심. + +활동 방향 +x 로 맞춘 `Prepared` 위의 좌표. 탐색(`Engine_Search`) · 결과 꾸미기(`Engine_Describe`)가 같이 씀. +""" + +import numpy as np + +from E03_StabSection import E03_StabSection_Engine_GLE as gle +from E03_StabSection import E03_StabSection_Engine_LEM as lem + +METHODS = ("ordinary", "bishop", "janbu", "spencer", "morgenstern_price") +_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, mp_seed=None) -> dict: + """방법별 F 묶음 — 출발값 차례 Fellenius → Bishop → (Janbu · Spencer · M-P). + + M-P = 임계면 몇 개에만(차례 계산이라 느림 · 탐색 고리에서 쓰지 않음) · 출발 참고값 = mp_seed 또는 Bishop. + """ + 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", "spencer", "morgenstern_price"} & set(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) + if "morgenstern_price" in methods: + ref = seed if mp_seed is None else np.where(np.isfinite(mp_seed), mp_seed, seed) + out["morgenstern_price"] = gle.morgenstern_price(sl, center, F0=ref, curve=False) + 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 diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py new file mode 100644 index 000000000..d7da920a5 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Router.py @@ -0,0 +1,476 @@ +"""E03 2D 측점 해석 API (요구 345 · 347 · 351 · PLAN 532-3). + +- `POST /api/projects/{id}/stab/analyze2d` — 전 측점 해석(뒤에서 · 바뀐 측점만) · 같은 지문 = 안 셈 +- `GET …/stab/progress` — 진행률 +- `GET …/stab/result2d` — 요약 + 측점 목록(판정 · 대표 단면 · 벽 수 · 지배 비탈) + 횡단 없는 측점 +- `GET …/stab/section/{station_key}` — 그 측점 지층 폴리곤 · 지하수선 · 임계면 · 후보 · 절편 · 격자 +- `PUT …/stab/representative` — 대표 단면 켬/끔 → 작업본 `E03_StabSection/representative.json` +- `GET …/stab/corridor3d` — 복사본 노선면(1 m 솎음 · 3D 색칠 보기용) + +입력 = E01 복사본(측점 · 횡단 · DTM) + E02 지반 조건(작업본 · 없으면 초기값) +계산 자리 = ③ 서버 단독. +결과 = 파생 저장본 `_derived/stab2d_station/<측점 지문>` · `_derived/stab2d_summary/<전체 지문>` — +측점 지문 = 엔진 코드 · 복사 지문 · 물성 · 조건 · 하중 · 지하수 갈래 · 그 측점 지층 · 벽. +공유 회사(보기만)는 `require_project_access` 가 POST · PUT 를 막음. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import logging +import os +import subprocess +import sys +import tempfile +import time +from functools import lru_cache +from pathlib import Path +from typing import Any +from uuid import UUID + +import numpy as np +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + read_derived, + write_derived, +) +from common_util.common_util_json import atomic_write_json +from common_util.common_util_station_key import chainage_key +from common_util.common_util_surface_sampler import DtmGridSampler +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Model as ground_model +from E02_StabGround import E02_StabGround_Router as e02 +from E03_StabSection import E03_StabSection_View as view +from E03_StabSection import E03_StabSection_Walls as walls_mod +from E03_StabSection import E03_StabSection_Worker as worker +from E03_StabSection.E03_StabSection_Engine_Model import ( + ground_from_cross, + section_model_from_b06, + widen_ground, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["E03 Stab Section"]) + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +KIND_STATION = worker.KIND_STATION +KIND_SUMMARY = "stab2d_summary" +REPRESENTATIVE = Path("E03_StabSection") / "representative.json" +ENGINE_FILES = ( + "E03_StabSection_Types.py", + "E03_StabSection_Engine_Model.py", + "E03_StabSection_Engine_LEM.py", + "E03_StabSection_Engine_Search.py", + "E03_StabSection_Engine_Station.py", +) +#: 3D 보기 노선면 솎음 간격(m) +CORRIDOR_STEP_M = 1.0 +#: 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험) +WORKERS: int | None = None + +# 진행 — 프로젝트 → {running, percent, message, error, done, total} · 프로세스 메모리 +_JOBS: dict[str, dict[str, Any]] = {} +_TASKS: set[asyncio.Task] = set() + + +class RepresentativeRequest(BaseModel): + overrides: dict[str, bool | None] + + +@lru_cache(maxsize=1) +def engine_hash() -> str: + """엔진 코드 지문 — 코드가 바뀌면 모든 측점을 다시 셈.""" + digest = hashlib.sha1() + for name in ENGINE_FILES: + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def _ground(root: Path) -> dict[str, Any]: + """E02 지반 조건 — 작업본 · 없으면 초기값(옛 정본 = 빠진 지하수 칸 기본값 · E02 화면과 같음).""" + ground = e02._read_working(root) or e02.ensure_initial(root) + return ground_model.upgrade(ground, ground_model.load_defaults()) + + +def _layers(resolved: dict[str, Any] | None) -> list | None: + """E02 측점 적용 지층 → 엔진 지층(위부터 [물성 id, 원지반 아래 바닥 깊이 | None]). + + 없으면 None(엔진 기본 = B06 라벨).""" + if not resolved or not resolved.get("upper"): + return None + if resolved.get("lower"): + return [[resolved["upper"], float(resolved["soil_thickness_m"])], [resolved["lower"], None]] + return [[resolved["upper"], None]] + + +@lru_cache(maxsize=4) +def _walls(source: str, _copy_fp: str) -> dict[str, dict[str, Any]]: + """측점별 벽(532-4 · Node 한 번 약 2 s) — 복사본은 동결이라 복사 지문으로 기억.""" + return walls_mod.walls_by_station(source) + + +def plan_run(root: Path, ground: dict[str, Any]) -> dict[str, Any]: + """측점마다 입력 · 지문 + 전체 지문(판정 기준 포함) — 복사본이 없으면 400.""" + try: + source = src.source_dir(root) + copy_fp = src.manifest(source)["source_fingerprint"] + stations = src.stations(source) + except (OSError, KeyError) as error: + raise HTTPException(status_code=400, detail="원본 복사본이 없습니다.") from error + seismic = ground.get("seismic") or {} + conditions = ["dry", "wet"] + (["seismic"] if seismic.get("enabled") else []) + shared = { + "materials": ground.get("materials"), + "conditions": conditions, + "seismic_kh": float(seismic.get("kh") or 0.0) if seismic.get("enabled") else 0.0, + "road_surcharge_kpa": float(ground.get("road_surcharge_kpa") or 0.0), + "groundwater": ground.get("groundwater"), + } + resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)} + try: + found = _walls(str(source), copy_fp) + except RuntimeError as error: + raise HTTPException(status_code=500, detail=str(error)) from error + items = [] + for station in stations: + key = chainage_key(station["chainage_m"]) + layers = _layers(resolved.get(key)) + entry = found.get(key) + parts = [engine_hash(), copy_fp, shared, key, layers] + ([entry] if entry else []) + items.append( + { + "key": key, + "chainage_m": station["chainage_m"], + "label": station.get("label"), + "ground_type": station.get("ground_type"), + "center": [station.get("center_x"), station.get("center_y")], + "layers": layers, + "walls": entry, + "fp": derived_key(*parts), # 벽 없는 측점 = 종전 지문 꼴 + } + ) + factors = ground.get("safety_factors") or {} + overall = derived_key([item["fp"] for item in items], factors) + return { + "source": source, + "ground": ground, + "shared": shared, + "items": items, + "factors": factors, + "fp": overall, + } + + +def _station_input(source: Path, item: dict[str, Any], sampler: Any) -> dict[str, Any]: + """측점 하나 엔진 입력 — 횡단 · 설계 · 넓힌 지반선(DTM 밖은 표본 그대로).""" + cross = src.cross_section(source, item["chainage_m"]) + try: + ground = widen_ground(cross, sampler) if sampler else ground_from_cross(cross) + except (KeyError, TypeError, ValueError): + ground = ground_from_cross(cross) + return { + "cross": cross, + "design": walls_mod.design_with_walls(cross["design"], item.get("walls")), + "walls": walls_mod.walls_of(item.get("walls")), + "ground": [list(p) for p in ground], + } + + +def _sampler(source: Path) -> Any: + try: + return DtmGridSampler.from_npz(src.terrain_grid_path(source)) + except (OSError, FileNotFoundError, ValueError): + logger.warning("E03 지형 격자 없음 — 횡단 표본 폭으로 해석: %s", source) + return None + + +def _meta(item: dict[str, Any], design: dict[str, Any]) -> dict[str, Any]: + keys = ("key", "chainage_m", "label", "ground_type", "center", "fp") + walls = len(walls_mod.walls_of(item.get("walls")) or []) + return { + **{k: item[k] for k in keys}, + "section_mode": design.get("section_mode"), + "walls": walls, + } + + +def _run_worker(job: dict[str, Any], emit: Any) -> None: + """일꾼 프로세스(`E03_StabSection_Worker`) — 한 줄마다 emit · 실패면 RuntimeError. + + stderr 는 파일로(파이프로 두면 경고가 쌓여 일꾼이 멈춤).""" + with tempfile.TemporaryDirectory(prefix="aislo_e03_") as folder: + path = Path(folder) / "job.json" + path.write_text(json.dumps(job, ensure_ascii=False), encoding="utf-8") + log = Path(folder) / "stderr.txt" + with log.open("w", encoding="utf-8") as errors: + process = subprocess.Popen( + [sys.executable, "-m", "E03_StabSection.E03_StabSection_Worker", str(path)], + cwd=PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=errors, + text=True, + encoding="utf-8", + ) + for line in process.stdout: + if line.startswith("{"): + emit(json.loads(line).get("done")) + code = process.wait() + if code != 0: + tail = log.read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1:] + raise RuntimeError(f"해석 일꾼 실패: {tail}") + + +def run_all(root: Path, plan: dict[str, Any], report: Any) -> int: + """지문이 없는 측점만 셈(일꾼 프로세스 병렬) → 측점 저장본 · 요약 저장본. 센 측점 수.""" + source, shared, items = plan["source"], plan["shared"], plan["items"] + todo = [it for it in items if read_derived(root, KIND_STATION, it["fp"]) is None] + if todo: + sampler = _sampler(source) + job_items = [] + for it in todo: + data = _station_input(source, it, sampler) + job_items.append({"fp": it["fp"], "meta": _meta(it, data["design"]), **data}) + job_items[-1]["layers"] = it["layers"] + job = { + "root": str(root), + "keep": max(3 * len(items), 6), + "workers": WORKERS or max(1, (os.cpu_count() or 2) - 2), + "conditions_ground": plan["ground"], + "kwargs": { + "materials": shared["materials"], + "conditions": list(shared["conditions"]), + "seismic_kh": shared["seismic_kh"], + "road_surcharge_kpa": shared["road_surcharge_kpa"], + }, + "items": job_items, + } + report(2, f"측점 {len(todo)} 개 해석 시작") + done = [0] + + def emit(_key: str | None) -> None: + done[0] += 1 + report(2 + 95 * done[0] // len(todo), f"측점 {done[0]} / {len(todo)}") + + if WORKERS == 1: + worker.run_job(job, emit) + else: + _run_worker(job, emit) + rows = [] + for it in items: + saved = read_derived(root, KIND_STATION, it["fp"]) + value = saved["value"] if saved else {"meta": _meta(it, {}), "out": None, "error": "없음"} + rows.append(view.station_row(value["meta"], value["out"], plan["factors"], value["error"])) + summary = { + "fingerprint": plan["fp"], + "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "conditions": shared["conditions"], + "method": view.PRIMARY, + "factors": plan["factors"], + "summary": view.summarize(rows, shared["conditions"]), + "stations": rows, + } + write_derived(root, KIND_SUMMARY, plan["fp"], summary) + return len(todo) + + +def _latest_summary(root: Path, fp: str) -> tuple[dict[str, Any] | None, bool]: + """(요약, 지금 입력과 같은지) — 같은 지문이 없으면 가장 최근 것(옛 결과).""" + saved = read_derived(root, KIND_SUMMARY, fp) + if saved: + return saved["value"], True + files = sorted( + derived_dir(root, KIND_SUMMARY).glob("*.json"), + key=lambda p: p.stat().st_mtime_ns, + reverse=True, + ) + for path in files: + old = read_derived(root, KIND_SUMMARY, path.stem) + if old: + return old["value"], False + return None, False + + +def _overrides(root: Path) -> dict[str, bool]: + path = root / REPRESENTATIVE + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return {k: bool(v) for k, v in (data.get("overrides") or {}).items() if v is not None} + + +@router.post("/{project_id}/stab/analyze2d", response_model=None) +async def analyze2d(project_id: UUID) -> dict[str, Any]: + """전 측점 해석(뒤에서) — 지문이 같으면 다시 안 셈(`fresh`) · 진행은 `GET …/stab/progress`.""" + root, _ = await e02._project(project_id) + plan = plan_run(root, _ground(root)) + if read_derived(root, KIND_SUMMARY, plan["fp"]): + return {"status": "fresh", "fingerprint": plan["fp"]} + key = str(project_id) + if (_JOBS.get(key) or {}).get("running"): + raise HTTPException(status_code=409, detail="이미 해석 중입니다.") + job = {"running": True, "percent": 0, "message": "시작", "error": None, "computed": 0} + _JOBS[key] = job + + def report(percent: int, message: str) -> None: + job.update(percent=int(percent), message=message) + + async def work() -> None: + try: + job["computed"] = await asyncio.to_thread(run_all, root, plan, report) + job.update(percent=100, message="끝") + except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림 + logger.exception("E03 2D 해석 실패: project_id=%s", project_id) + job["error"] = str(getattr(error, "detail", None) or error) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E03 analyze2d {project_id}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return {"status": "accepted", "fingerprint": plan["fp"], **job} + + +@router.get("/{project_id}/stab/progress", response_model=None) +async def progress(project_id: UUID) -> dict[str, Any]: + await e02._project(project_id) + job = _JOBS.get(str(project_id)) + return dict(job) if job else {"running": False, "percent": 0, "message": "", "error": None} + + +@router.get("/{project_id}/stab/result2d", response_model=None) +async def result2d( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + """요약 + 측점 목록 — 결과가 없으면 복사본 측점만(status none).""" + root, project = await e02._project(project_id) + ground = _ground(root) + plan = plan_run(root, ground) + summary, fresh = _latest_summary(root, plan["fp"]) + editable = e02._editable(session, project) + running = bool((_JOBS.get(str(project_id)) or {}).get("running")) + missing = src.missing_stations(plan["source"]) # 횡단 없어 해석에서 빠진 측점(화면 띠) + if not summary: + rows = [view.station_row(_meta(item, {}), None, plan["factors"]) for item in plan["items"]] + view.mark_representative(rows, _overrides(root)) + rows = [{**r, **view.governing_row(r)} for r in rows] + return { + "status": "none", + "fresh": False, + "editable": editable, + "running": running, + "conditions": plan["shared"]["conditions"], + "factors": plan["factors"], + "stations": rows, + "missing_stations": missing, + } + rows = summary["stations"] + view.mark_representative(rows, _overrides(root)) + rows = [{**r, **view.governing_row(r)} for r in rows] + return { + "status": "ok", + "fresh": fresh, + "editable": editable, + "running": running, + **{k: v for k, v in summary.items() if k != "stations"}, + "stations": rows, + "missing_stations": missing, + } + + +@router.get("/{project_id}/stab/section/{station_key}", response_model=None) +async def section(project_id: UUID, station_key: str) -> dict[str, Any]: + """측점 하나 — 해석에 쓴 지반선 · 지층으로 모형을 다시 세워(빠름) 결과와 함께.""" + root, _ = await e02._project(project_id) + ground = _ground(root) + plan = plan_run(root, ground) + summary, fresh = _latest_summary(root, plan["fp"]) + row = next((r for r in (summary or {}).get("stations", []) if r["key"] == station_key), None) + saved = read_derived(root, KIND_STATION, row["fp"]) if row else None + if not saved or not saved["value"]["out"]: + raise HTTPException(status_code=404, detail="그 측점 해석 결과가 없습니다.") + value = saved["value"] + cross = src.cross_section(plan["source"], row["chainage_m"]) + entry = next((it["walls"] for it in plan["items"] if it["key"] == station_key), None) + design = walls_mod.design_with_walls(cross["design"], entry) + kw = { + "materials": plan["shared"]["materials"], + "layers": value["inputs"]["layers"], + "ground": [tuple(p) for p in value["inputs"]["ground"]], + "road_surcharge_kpa": plan["shared"]["road_surcharge_kpa"], + "walls": walls_mod.walls_of(entry), + } + dry = section_model_from_b06(cross, design, **kw) + water, rules = {}, {} + for cond in summary["conditions"]: + # 수위선 = E02 고른 갈래(r_u · 없음 = 선 없음) + model = section_model_from_b06(cross, design, wet=cond == "wet", **kw) + gw = ground_model.station_groundwater(ground, cond, model) + water[cond] = gw.get("line") if gw.get("mode") == "line" else None + rules[cond] = {"mode": gw.get("mode"), "ru": gw.get("ru")} + out = view.section_view(row, value["out"], dry, water) + out["water_rule"] = rules + out["fresh"] = fresh + return out + + +@router.put("/{project_id}/stab/representative", response_model=None) +async def put_representative(project_id: UUID, payload: RepresentativeRequest) -> dict[str, Any]: + """대표 단면 켬/끔(None = 자동) — 아는 측점 열쇠만.""" + root, _ = await e02._project(project_id) + known = {item["key"] for item in plan_run(root, _ground(root))["items"]} + unknown = sorted(set(payload.overrides) - known) + if unknown: + raise HTTPException(status_code=422, detail=f"모르는 측점: {unknown[:5]}") + overrides = {k: v for k, v in sorted(payload.overrides.items()) if v is not None} + atomic_write_json(root / REPRESENTATIVE, {"overrides": overrides}) + return {"overrides": overrides} + + +@lru_cache(maxsize=2) +def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + ribbons = [] + origin = None + for ribbon in data.get("ribbons") or []: + cols = int(ribbon["colCount"]) + chain = np.asarray(ribbon["chainages"], dtype=float) + pos = np.frombuffer(base64.b64decode(ribbon["positionsBase64"]), dtype=" 0]) + if keep[-1] != len(chain) - 1: + keep = np.r_[keep, len(chain) - 1] + local = pos[keep] - np.asarray(origin) + ribbons.append( + { + "kind": ribbon["kind"], + "side": ribbon["side"], + "colCount": cols, + "chainages": np.round(chain[keep], 3).tolist(), + "positions": np.round(local, 3).reshape(-1).tolist(), + } + ) + return {"origin": origin, "ribbons": ribbons} + + +@router.get("/{project_id}/stab/corridor3d", response_model=None) +async def corridor3d(project_id: UUID) -> dict[str, Any]: + """복사본 노선면 — 원점 빼고 1 m 솎음(모델 좌표 x · y · z).""" + root, _ = await e02._project(project_id) + path = src.corridor_path(src.source_dir(root)) + if not path: + raise HTTPException(status_code=404, detail="복사본에 노선면이 없습니다.") + stat = path.stat() + return await asyncio.to_thread(_corridor3d, str(path), (stat.st_mtime_ns, stat.st_size)) diff --git a/E03_StabSection/E03_StabSection_Router_Prob.py b/E03_StabSection/E03_StabSection_Router_Prob.py new file mode 100644 index 000000000..bc8e4fcfe --- /dev/null +++ b/E03_StabSection/E03_StabSection_Router_Prob.py @@ -0,0 +1,400 @@ +"""E03 확률 해석 · 민감도 API (요구 351 · PLAN 537-1) — 대표 단면만. + +- `GET /api/projects/{id}/stab/prob/setup/{station_key}?side=&condition=` — 그 단면 지층 물성(E02 값 = 평균) · + 기본값 후보(`prob_defaults.json` · 근거 · 사용자 확정 전) · 지하수 갈래 · 기준안전율 · 마지막 결과 +- `POST …/stab/prob/{station_key}` — 분포 · 표본 수 · 갈래 → 뒤에서(진행 `GET …/stab/prob/progress`) · + 같은 지문 = 저장본(fresh) +- `GET …/stab/prob` — 지금 2D 결과에 붙은 기록 목록(측점 · 비탈 · 조건마다 가장 최근 · E05 「확률해석」 시트) + +평균 = E02 지반 조건(작업본) 값 · 바꾸려면 E02 에서. 임계면 = 지금 2D 결과의 그 방법 최소 면. +결과 = 파생 저장본 `_derived/stab2d_prob/<지문>` · 지문 = 원본 측점 지문 + 요청 + 코드. +엔진 = `Engine_Prob` — 임계면 고정 = 서버 스레드(빠름) · 매회 탐색 = 일꾼 프로세스(풀 · 느림). +계산 자리 = ③ 서버 단독. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import os +import subprocess +import sys +import tempfile +import time +from functools import lru_cache +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + read_derived, + write_derived, +) +from E02_StabGround import E02_StabGround_Router as e02 +from E03_StabSection import E03_StabSection_Engine_Prob as prob +from E03_StabSection import E03_StabSection_Router as e03 +from E03_StabSection import E03_StabSection_Router_WhatIf as whatif +from E03_StabSection import E03_StabSection_View as view +from E03_StabSection.E03_StabSection_Engine_LEM import WALL_ID +from E03_StabSection.E03_StabSection_Engine_Model import section_model_from_b06 +from E03_StabSection.E03_StabSection_Types import groundwater as norm_groundwater + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["E03 Stab Probabilistic"]) +KIND = "stab2d_prob" +KEEP = 200 +DEFAULTS_PATH = ( + Path(__file__).resolve().parents[1] / "resources/master_template/stability/prob_defaults.json" +) +#: 매회 탐색 표본 수 상한(측점 하나 탐색 수 초 × N) +SEARCH_MAX_N = 2000 +#: 일꾼 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험) +WORKERS: int | None = None + +_JOBS: dict[str, dict[str, Any]] = {} +_TASKS: set[asyncio.Task] = set() + + +@lru_cache(maxsize=1) +def code_hash() -> str: + digest = hashlib.sha1(e03.engine_hash().encode()) + for name in ("E03_StabSection_Engine_Prob.py", Path(__file__).name): + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def load_defaults() -> dict[str, Any]: + return json.loads(DEFAULTS_PATH.read_text(encoding="utf-8")) + + +class VarIn(BaseModel): + """확률 변수 하나 — c · φ · γ = 변동계수 · 지하수위 이동(m) · r_u = 표준편차 · 절단 = 평균 ± k·σ.""" + + material: str | None = None + prop: Literal["c", "phi", "gamma", "water", "ru"] + dist: Literal["normal", "lognormal"] = "normal" + cov: float | None = Field(None, ge=0.0, le=2.0) + std: float | None = Field(None, ge=0.0, le=20.0) + trunc_sigma: float | None = Field(3.0, gt=0.0, le=10.0) + + +class ProbRequest(BaseModel): + side: Literal["left", "right"] + condition: Literal["dry", "wet", "seismic"] + method: Literal["ordinary", "bishop", "janbu", "spencer", "morgenstern_price"] = "bishop" + mode: Literal["fixed", "search"] = "fixed" + n: int = Field(1000, ge=50, le=20000) + sampling: Literal["lhs", "mc"] = "lhs" + seed: int = Field(0, ge=0, le=2**31 - 1) + variables: list[VarIn] = Field(default_factory=list, max_length=40) + rho_c_phi: float = Field(0.0, ge=-0.95, le=0.95) + + +def _model(ctx: dict[str, Any], condition: str) -> dict[str, Any]: + """그 조건 모형 — `analyze_station` 과 같은 입력(지층 · 지반선 · 벽 · 하중 · E02 지하수 갈래).""" + kw = ctx["kw"] + model = section_model_from_b06( + ctx["cross"], + ctx["design"], + materials=kw["materials"], + layers=kw["layers"], + ground=kw["ground"], + wet=condition == "wet", + seismic_kh=kw["seismic_kh"] if condition == "seismic" else 0.0, + road_surcharge_kpa=kw["road_surcharge_kpa"], + walls=kw.get("walls"), + ) + model["groundwater"] = norm_groundwater(kw["groundwater_for"](condition, model)) + return model + + +def _slot(root: Path, station_key: str, side: str, condition: str, method: str) -> dict: + """측점 · 비탈 · 조건 — 대표 단면만 · 모형 · 임계면 · 기준안전율.""" + ctx = whatif._context(root, station_key) + rows = [dict(r) for r in e03._latest_summary(root, ctx["plan"]["fp"])[0]["stations"]] + view.mark_representative(rows, e03._overrides(root)) + mark = next((r.get("representative") or {} for r in rows if r["key"] == station_key), {}) + if not mark.get("on"): + raise HTTPException( + status_code=422, detail="대표 단면만 확률 해석합니다 — 먼저 대표 단면으로." + ) + if condition not in ctx["kw"]["conditions"]: + raise HTTPException(status_code=422, detail="그 조건은 해석하지 않았습니다.") + case = next( + ( + c + for c in ctx["value"]["out"]["cases"] + if c["condition"] == condition and c["side"] == side + ), + None, + ) + if case is None: + raise HTTPException(status_code=422, detail="그 쪽 비탈이 없습니다.") + methods = case["result"]["methods"] + surface = (methods.get(method) or {}).get("min") or None + model = _model(ctx, condition) + sd = next(s for s in model["sides"] if s["side"] == side) + return { + "ctx": ctx, + "model": model, + "side": sd, + "role": case["role"], + "surface": surface, + "methods": methods, + "required": view.required_factor(ctx["plan"]["factors"], condition, case["role"]), + } + + +def _materials(model: dict[str, Any]) -> list[dict[str, Any]]: + """단면에 쓰인 물성(벽 뺌 · 지층 차례).""" + used = list(dict.fromkeys(r["material_id"] for r in model["regions"])) + by = {m["id"]: m for m in model["materials"]} + return [by[i] for i in used if i != WALL_ID and i in by] + + +def _variables(req: ProbRequest, model: dict[str, Any]) -> tuple[list[dict], list]: + """요청 → 엔진 변수(평균 = 모형 값 · σ 0 은 뺌) · c–φ 상관 짝.""" + mats = {m["id"]: m for m in _materials(model)} + gw = model.get("groundwater") or {} + out = [] + for v in req.variables: + if v.prop in ("water", "ru"): + need = "line" if v.prop == "water" else "ru" + if gw.get("mode") != need: + raise HTTPException( + status_code=422, + detail=f"{'지하수위' if v.prop == 'water' else 'r_u'} 변수는 그 조건 지하수 갈래가 " + f"{'수위선' if need == 'line' else 'r_u'} 일 때만.", + ) + mean, std = (0.0, v.std) if v.prop == "water" else (float(gw["ru"]), v.std) + name = "지하수위" if v.prop == "water" else "r_u" + else: + m = mats.get(v.material or "") + if m is None: + raise HTTPException(status_code=422, detail=f"단면에 없는 물성: {v.material}") + mean = float(m["gamma_t"] if v.prop == "gamma" else m[v.prop]) + std = mean * float(v.cov) if v.cov is not None else v.std + name = f"{m.get('name') or m['id']} {dict(c='c', phi='φ', gamma='γ')[v.prop]}" + if not std: + continue + if v.dist == "lognormal" and (mean <= 0 or v.prop in ("water",)): + raise HTTPException(status_code=422, detail=f"대수정규는 평균 > 0 만: {name}") + k = v.trunc_sigma + out.append( + { + "name": name, + "material": v.material if v.prop not in ("water", "ru") else None, + "prop": v.prop, + "dist": v.dist, + "mean": mean, + "std": float(std), + "lo": None if k is None else k * float(std), + "hi": None if k is None else k * float(std), + } + ) + pairs = [] + if req.rho_c_phi: + for i, a in enumerate(out): + for j, b in enumerate(out): + if a["prop"] == "c" and b["prop"] == "phi" and a["material"] == b["material"]: + pairs.append((i, j, req.rho_c_phi)) + return out, pairs + + +def _run_search(job: dict[str, Any], emit: Any) -> dict[str, Any]: + """매회 탐색 일꾼(`python -m …Engine_Prob job.json`) — 한 줄마다 emit · 실패면 RuntimeError.""" + with tempfile.TemporaryDirectory(prefix="aislo_e03p_") as folder: + path, out = Path(folder) / "job.json", Path(folder) / "out.json" + path.write_text(json.dumps({**job, "out": str(out)}, ensure_ascii=False), encoding="utf-8") + log = Path(folder) / "stderr.txt" + with log.open("w", encoding="utf-8") as errors: + process = subprocess.Popen( + [sys.executable, "-m", "E03_StabSection.E03_StabSection_Engine_Prob", str(path)], + cwd=e03.PROJECT_ROOT, + stdout=subprocess.PIPE, + stderr=errors, + text=True, + encoding="utf-8", + ) + for line in process.stdout: + if line.startswith("{"): + emit(json.loads(line).get("done")) + code = process.wait() + if code != 0: + tail = log.read_text(encoding="utf-8", errors="replace").strip().splitlines()[-1:] + raise RuntimeError(f"확률 해석 일꾼 실패: {tail}") + return json.loads(out.read_text(encoding="utf-8")) + + +def run_prob(root: Path, station_key: str, req: ProbRequest, report: Any) -> dict[str, Any]: + slot = _slot(root, station_key, req.side, req.condition, req.method) + if slot["surface"] is None: + raise HTTPException(status_code=422, detail="그 방법 임계면이 없습니다(해 없음).") + if req.mode == "search" and req.n > SEARCH_MAX_N: + raise HTTPException(status_code=422, detail=f"매회 탐색은 표본 {SEARCH_MAX_N} 회까지.") + variables, pairs = _variables(req, slot["model"]) + if not variables: + raise HTTPException(status_code=422, detail="확률 변수가 없습니다(변동계수 · 표준편차 0).") + row = slot["ctx"]["row"] + key = derived_key(code_hash(), row["fp"], req.model_dump()) + cached = read_derived(root, KIND, key) + if cached: + return cached["value"] + job = { + "model": slot["model"], + "surface": slot["surface"], + "direction": slot["side"]["direction"], + "limits": list(slot["side"]["limits"]), + "variables": variables, + "correlations": pairs, + "n": req.n, + "sampling": req.sampling, + "seed": req.seed, + "mode": req.mode, + "method": req.method, + "required": slot["required"], + } + + def emit(done: int | None) -> None: + if done: + report(2 + 93 * int(done) // req.n, f"표본 {done} / {req.n}") + + report(1, "시작") + workers = WORKERS or max(1, (os.cpu_count() or 2) - 2) + if req.mode == "search" and workers > 1: + result = _run_search({**job, "workers": workers}, emit) + else: + result = prob.run(**job, emit=emit) + record = { + "station_key": station_key, + "chainage_m": row["chainage_m"], + "label": row.get("label"), + "base_fp": row["fp"], + "side": req.side, + "role": slot["role"], + "condition": req.condition, + "required": slot["required"], + "fs_deterministic": slot["surface"]["F"], + "surface_kind": slot["surface"]["kind"], + "request": req.model_dump(), + "result": result, + "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + write_derived(root, KIND, key, record, keep=KEEP) + return record + + +def records(root: Path) -> list[dict[str, Any]]: + """가장 최근 2D 요약 측점에 붙은 기록 — (측점 · 비탈 · 조건)마다 가장 최근 하나 · 누가거리 순.""" + summary, _ = e03._latest_summary(root, "") + fps = {r["fp"] for r in (summary or {}).get("stations") or []} + latest: dict[tuple, dict[str, Any]] = {} + for path in derived_dir(root, KIND).glob("*.json"): + value = (read_derived(root, KIND, path.stem) or {}).get("value") + if not value or value.get("base_fp") not in fps: + continue + slot = (value["station_key"], value["side"], value["condition"]) + if slot not in latest or value["computed_at"] > latest[slot]["computed_at"]: + latest[slot] = value + return sorted( + latest.values(), key=lambda v: (float(v["chainage_m"]), v["side"], v["condition"]) + ) + + +def setup(root: Path, station_key: str, side: str, condition: str) -> dict[str, Any]: + slot = _slot(root, station_key, side, condition, "bishop") + gw = slot["model"].get("groundwater") or {} + last = next( + ( + r + for r in records(root) + if (r["station_key"], r["side"], r["condition"]) == (station_key, side, condition) + ), + None, + ) + return { + "station_key": station_key, + "side": side, + "condition": condition, + "role": slot["role"], + "required": slot["required"], + "fs_deterministic": {m: (v.get("min") or {}).get("F") for m, v in slot["methods"].items()}, + "materials": [ + {k: m.get(k) for k in ("id", "name", "c", "phi", "gamma_t")} + for m in _materials(slot["model"]) + ], + "groundwater": {"mode": gw.get("mode", "none"), "ru": gw.get("ru")}, + "defaults": load_defaults(), + "search_max_n": SEARCH_MAX_N, + "last": last, + } + + +@router.get("/{project_id}/stab/prob/setup/{station_key}", response_model=None) +async def get_setup(project_id: UUID, station_key: str, side: str, condition: str) -> dict: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(setup, root, station_key, side, condition) + + +@router.post("/{project_id}/stab/prob/{station_key}", response_model=None) +async def post_prob(project_id: UUID, station_key: str, payload: ProbRequest) -> dict: + """뒤에서 — 같은 지문이면 바로 저장본(fresh) · 진행 = `GET …/stab/prob/progress`.""" + root, _ = await e02._project(project_id) + key = str(project_id) + if (_JOBS.get(key) or {}).get("running"): + raise HTTPException(status_code=409, detail="이미 확률 해석 중입니다.") + # 입력 검사 · 저장본 확인은 바로(422 를 그 자리에서 돌려줌) + slot = await asyncio.to_thread( + _slot, root, station_key, payload.side, payload.condition, payload.method + ) + _variables(payload, slot["model"]) + fp = derived_key(code_hash(), slot["ctx"]["row"]["fp"], payload.model_dump()) + saved = read_derived(root, KIND, fp) + if saved: + return {"status": "fresh", "record": saved["value"]} + job = { + "running": True, + "percent": 0, + "message": "시작", + "error": None, + "station_key": station_key, + } + _JOBS[key] = job + + def report(percent: int, message: str) -> None: + job.update(percent=int(percent), message=message) + + async def work() -> None: + try: + await asyncio.to_thread(run_prob, root, station_key, payload, report) + job.update(percent=100, message="끝") + except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림 + logger.exception("E03 확률 해석 실패: project_id=%s", project_id) + job["error"] = str(getattr(error, "detail", None) or error) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E03 prob {project_id}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return {"status": "accepted", **job} + + +@router.get("/{project_id}/stab/prob/progress", response_model=None) +async def get_progress(project_id: UUID) -> dict: + await e02._project(project_id) + job = _JOBS.get(str(project_id)) + return dict(job) if job else {"running": False, "percent": 0, "message": "", "error": None} + + +@router.get("/{project_id}/stab/prob", response_model=None) +async def get_records(project_id: UUID) -> dict: + root, _ = await e02._project(project_id) + return {"records": await asyncio.to_thread(records, root)} diff --git a/E03_StabSection/E03_StabSection_Router_WhatIf.py b/E03_StabSection/E03_StabSection_Router_WhatIf.py new file mode 100644 index 000000000..9380dda50 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Router_WhatIf.py @@ -0,0 +1,433 @@ +"""E03 보강 검토 API — 비탈 완화 · 소단 가정 재해석 · 벽 외적 안정 (요구 345 · 351 · PLAN 536-3). + +- `POST /api/projects/{id}/stab/whatif/{station_key}` — 가정(비탈 · 1:n · 소단 폭 · 높이 · + 최소 완화 찾기 · 네일 · 앵커 `supports` · 최소 길이 찾기) → 형상만 바꾼 모형으로 + `analyze_station` 다시 · 전후 안전율 · 판정 · 바뀐 단면(화면 꼴) · + 네일마다 동원력 · mode(엔진 `cases[i].result.support_forces`) +- `POST …/stab/wallcheck/{station_key}` — 그 측점 벽마다 활동 · 전도 · 지지력(평상시 · 지진 시) +- `GET …/stab/whatif` — 지금 2D 결과에 붙은 가정 · 벽 검토 목록(단면 꼴 뺌 · E05 「보강검토」 시트) + +가정 결과 = 파생 저장본 `_derived/stab2d_whatif/<지문>` + 단면 꼴 `stab2d_whatif_view/` (원본 결과 그대로) · +지문 = 원본 측점 지문 + 가정. 엔진 = 부르기만(`analyze_station` · `section_model_from_b06`). +계산 자리 = ③ 서버 단독. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import inspect +import math +import time +from pathlib import Path +from typing import Any, Literal +from functools import lru_cache, partial +from uuid import UUID + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field, model_validator +from shapely.geometry import Point, Polygon + +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + read_derived, + write_derived, +) +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Model as ground_model +from E02_StabGround import E02_StabGround_Router as e02 +from E03_StabSection import E03_StabSection_Router as e03 +from E03_StabSection import E03_StabSection_View as view +from E03_StabSection import E03_StabSection_Walls as walls_mod +from E03_StabSection import E03_StabSection_WallCheck as wallcheck +from E03_StabSection import E03_StabSection_WhatIf as whatif +from E03_StabSection.E03_StabSection_Engine_Model import WALL_MATERIAL, section_model_from_b06 +from E03_StabSection.E03_StabSection_Engine_Station import analyze_station + +router = APIRouter(prefix="/api/projects", tags=["E03 Stab What-if"]) +KIND = "stab2d_whatif" +KIND_VIEW = "stab2d_whatif_view" # 바뀐 단면 화면 꼴(무거움 · 목록은 안 읽음) +KEEP = 300 +#: 최소 길이 찾기 — 가장 긴 길이 · 찾는 단위(m) +SUPPORT_MAX_LEN = 25.0 +SUPPORT_LEN_STEP = 0.5 + + +@lru_cache(maxsize=1) +def code_hash() -> str: + """가정 · 벽 검토 코드 지문 — 바뀌면 저장본을 다시 셈(엔진 지문은 원본 측점 지문에 있음).""" + digest = hashlib.sha1() + for name in ("E03_StabSection_WhatIf.py", "E03_StabSection_WallCheck.py", Path(__file__).name): + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +class SupportIn(BaseModel): + """네일 · 앵커 하나 — 브레인 정함 꼴(sub4 엔진 같은 이름) · side 없으면 요청 비탈.""" + + type: Literal["nail", "anchor"] + side: Literal["left", "right"] | None = None + x: float + z: float + angle_deg: float = Field(ge=-10.0, le=80.0) + length_m: float = Field(gt=0.0, le=50.0) + spacing_m: float = Field(gt=0.1, le=10.0) + tensile_kN: float = Field(gt=0.0) + bond_kPa: float = Field(gt=0.0) + bond_length_m: float = Field(0.0, ge=0.0, le=50.0) + hole_dia_mm: float = Field(gt=0.0, le=500.0) + prestress_kN: float = Field(0.0, ge=0.0) + + @model_validator(mode="after") + def _anchor_bond(self) -> "SupportIn": + if self.type == "anchor" and not 0 < self.bond_length_m < self.length_m: + raise ValueError("앵커 정착장은 0 초과 · 길이 미만") + return self + + +class WhatIfRequest(BaseModel): + side: Literal["left", "right"] + ratio: float | None = Field(None, gt=0.05, le=whatif.MAX_RATIO) + bench_w: float = Field(0.0, ge=0.0, le=10.0) + bench_h: float = Field(0.0, ge=0.0, le=30.0) + search: bool = False + #: 최소 완화 찾기 목표 — 조건 하나(None = 모든 조건) · 목표 안전율(None = 그 조건 기준안전율) + condition: Literal["dry", "wet", "seismic"] | None = None + target_fs: float | None = Field(None, gt=0.5, le=5.0) + #: 네일 · 앵커 — 있으면 search = 최소 길이 찾기(모두 같은 길이 · 앵커 정착장 그대로) + supports: list[SupportIn] = Field(default_factory=list, max_length=40) + + +def engine_takes_supports() -> bool: + """sub4 엔진에 supports 인자가 들어왔는지 — 없으면 네일 · 앵커는 그림만(결과 = 보강 없음).""" + return "supports" in inspect.signature(analyze_station).parameters + + +def _sized(supports: list[dict], length: float | None) -> list[dict]: + if length is None: + return supports + return [ + {**s, "length_m": max(length, s["bond_length_m"] + 0.5 * (s["type"] == "anchor"))} + for s in supports + ] + + +def _forces(out: dict, side: str) -> dict[str, list]: + """조건마다 그 비탈 임계면 기준 네일 힘(엔진 `cases[i].result.support_forces`).""" + return { + c["condition"]: c["result"]["support_forces"] + for c in out.get("cases") or [] + if c.get("side") == side and (c.get("result") or {}).get("support_forces") is not None + } + + +class WallRequest(BaseModel): + method: Literal["coulomb", "rankine"] = "coulomb" + water_ratio: float = Field(0.0, ge=0.0, le=1.0) + kh: float | None = Field(None, ge=0.0, le=0.5) + + +def _context(root: Path, station_key: str) -> dict[str, Any]: + """측점 하나 — 지금 입력과 같은 2D 결과 · 원본 저장본 · 엔진 입력(일꾼과 같은 값).""" + ground = e03._ground(root) + plan = e03.plan_run(root, ground) + summary, fresh = e03._latest_summary(root, plan["fp"]) + if not summary or not fresh: + raise HTTPException(status_code=409, detail="2D 결과가 지금 입력과 다릅니다 — 먼저 [해석].") + row = next((r for r in summary["stations"] if r["key"] == station_key), None) + saved = read_derived(root, e03.KIND_STATION, row["fp"]) if row else None + if not saved or not saved["value"]["out"]: + raise HTTPException(status_code=404, detail="그 측점 해석 결과가 없습니다.") + value = saved["value"] + cross = src.cross_section(plan["source"], row["chainage_m"]) + entry = next((it["walls"] for it in plan["items"] if it["key"] == station_key), None) + shared = plan["shared"] + kw = { + "materials": shared["materials"], + "conditions": list(shared["conditions"]), + "seismic_kh": shared["seismic_kh"], + "road_surcharge_kpa": shared["road_surcharge_kpa"], + "layers": value["inputs"]["layers"], + "ground": [tuple(p) for p in value["inputs"]["ground"]], + } + walls = walls_mod.walls_of(entry) + if walls: + kw["walls"] = walls + kw["groundwater_for"] = partial(ground_model.station_groundwater, ground) + return { + "ground": ground, + "plan": plan, + "row": row, + "value": value, + "cross": cross, + "design": walls_mod.design_with_walls(cross["design"], entry), + "walls": walls or [], + "kw": kw, + } + + +def _wall_side(wall: dict[str, Any]) -> str: + return "left" if Polygon(wall["polygon"]).centroid.x > 0 else "right" + + +def _section(ctx: dict[str, Any], design: dict[str, Any], row: dict, out: dict) -> dict: + """바뀐 단면 화면 꼴 — `GET …/section` 과 같은 꼴(지하수선 = E02 고른 갈래).""" + kw = {k: ctx["kw"][k] for k in ("materials", "layers", "ground", "road_surcharge_kpa")} + kw["walls"] = ctx["kw"].get("walls") + dry = section_model_from_b06(ctx["cross"], design, **kw) + water = {} + for cond in ctx["kw"]["conditions"]: + model = section_model_from_b06(ctx["cross"], design, wet=cond == "wet", **kw) + gw = ground_model.station_groundwater(ctx["ground"], cond, model) + water[cond] = gw.get("line") if gw.get("mode") == "line" else None + return view.section_view(row, out, dry, water) + + +def _side_cases(row: dict[str, Any], side: str) -> dict[str, Any]: + return {cond: by[side] for cond, by in (row.get("cases") or {}).items() if side in by} + + +def run_whatif(root: Path, station_key: str, req: WhatIfRequest) -> dict[str, Any]: + ctx = _context(root, station_key) + row, design, ground = ctx["row"], ctx["design"], ctx["kw"]["ground"] + role = {s["side"]: s["role"] for s in row["sides"]}.get(req.side) + if role is None: + raise HTTPException(status_code=422, detail="그 쪽 비탈이 없습니다.") + if any(_wall_side(w) == req.side for w in ctx["walls"]): + raise HTTPException(status_code=422, detail="벽이 선 비탈 — 벽 외적 안정으로 검토하세요.") + before_ratio = whatif.current_ratio(design, req.side, role) + assume = { + "side": req.side, + "role": role, + "ratio": float(req.ratio or before_ratio), + "bench_w": req.bench_w if req.bench_h > 0 else 0.0, + "bench_h": req.bench_h if req.bench_w > 0 else 0.0, + } + goal = {"condition": req.condition, "target_fs": req.target_fs} if req.search else None + supports = [{**s.model_dump(), "side": s.side or req.side} for s in req.supports] + engine = engine_takes_supports() + key = derived_key(code_hash(), row["fp"], assume, goal, supports, engine) + cached, shown = read_derived(root, KIND, key), read_derived(root, KIND_VIEW, key) + if cached and shown: + return {**cached["value"], "view": shown["value"]} + t0 = time.perf_counter() + meta, factors = ctx["value"]["meta"], ctx["plan"]["factors"] + + memo: dict[tuple, tuple[dict, dict] | None] = {} + same = not assume["bench_w"] # 지금 기울기 그대로 = 원본 설계선(다시 그리지 않음) + + def run(n: float, length: float | None = None) -> tuple[dict, dict] | None: + n = round(n, 4) + if (n, length) not in memo: + if same and abs(n - before_ratio) < 1e-9: + variant = design + else: # None = 해석 폭 안에서 원지반을 못 만남(자연 비탈보다 완만) + variant = whatif.design_variant(design, ground, {**assume, "ratio": n}) + kw = dict(ctx["kw"]) + if supports and engine: + kw["supports"] = _sized(supports, length) + memo[(n, length)] = variant and (analyze_station(ctx["cross"], variant, **kw), variant) + return memo[(n, length)] + + reached: list[float] = [] # 원지반을 만난 기울기 + + def meets(done: tuple[dict, dict] | None) -> bool: + if done is None: + return False + cases = _side_cases(view.station_row(meta, done[0], factors), req.side) + if req.condition: + cases = {k: v for k, v in cases.items() if k == req.condition} + need = (lambda c: c["required"]) if req.target_fs is None else (lambda c: req.target_fs) + return bool(cases) and all( + c["fs"] is not None and need(c) is not None and c["fs"] >= need(c) + for c in cases.values() + ) + + def passes(n: float) -> bool: + done = run(n) # 원본과 같은 방법 · 조건(방법을 줄이면 탐색 씨앗이 달라 값이 바뀜) + if done is not None: + reached.append(n) + return meets(done) + + search = None + length = None + if req.search and supports: + start = max([2.0] + [s["bond_length_m"] + 0.5 for s in supports if s["type"] == "anchor"]) + if engine: + found = whatif.search_ratio( + lambda L: meets(run(assume["ratio"], L)), + start, + max_ratio=SUPPORT_MAX_LEN, + step=SUPPORT_LEN_STEP, + # 길이 대 Fs 가 늘 단조롭지 않음(활동면이 깊어짐) — 1 m 성긴 걸음 뒤 이분 + coarse=max(1, round(SUPPORT_MAX_LEN - start)), + ) + else: + found = {"found": None, "tried": []} + search = {**found, "by": "length", "longest": SUPPORT_MAX_LEN} + length = found["found"] or SUPPORT_MAX_LEN + elif req.search: + top = whatif.gentlest_ratio(design, ground, assume, before_ratio) or before_ratio + search = {**whatif.search_ratio(passes, before_ratio, max_ratio=top), "gentlest": top} + # 못 찾으면 원지반을 만난 가장 완만한 기울기로 보여 줌 + assume["ratio"] = search["found"] or max(reached, default=before_ratio) + assume["ratio"] = round(assume["ratio"], 4) + done = run(assume["ratio"], length) + if done is None: + raise HTTPException( + status_code=422, + detail=f"가정 비탈(1:{assume['ratio']:g} · 소단 포함)이 해석 폭 안에서 원지반을 못 만납니다" + " — 자연 비탈보다 완만.", + ) + out, variant = done + after = view.station_row(meta, out, factors) + if supports: + assume["supports"] = _sized(supports, length) + record = { + "type": "support" if supports else "slope", + "station_key": station_key, + "chainage_m": row["chainage_m"], + "label": row.get("label"), + "base_fp": row["fp"], + "assume": {**assume, "ratio_before": before_ratio}, + "search": search and {**search, **goal}, + "before": _side_cases(row, req.side), + "after": _side_cases(after, req.side), + # 엔진 고리 전 = 네일 · 앵커 힘 없음(결과 = 보강 없는 재해석 · 화면은 그림만) + "support_forces": _forces(out, req.side) if supports else None, + "supports_pending": bool(supports) and not engine, + "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + "elapsed_s": round(time.perf_counter() - t0, 2), + } + shown = _section(ctx, variant, after, out) + write_derived(root, KIND_VIEW, key, shown, keep=KEEP) + write_derived(root, KIND, key, record, keep=KEEP) + return {**record, "view": shown} + + +def _material_at(model: dict[str, Any], x: float, z: float, skip: str = "wall") -> str | None: + pt = Point(x, z) + for region in model["regions"]: + if region["material_id"] != skip and Polygon(region["polygon"]).buffer(1e-6).contains(pt): + return region["material_id"] + return None + + +def _surface_z(surface: list, x: float) -> float: + pts = sorted(surface) + for (xa, za), (xb, zb) in zip(pts, pts[1:]): + if xa <= x <= xb: + return za + (zb - za) * ((x - xa) / (xb - xa) if xb > xa else 0.0) + return pts[0][1] if x < pts[0][0] else pts[-1][1] + + +def run_wallcheck(root: Path, station_key: str, req: WallRequest) -> dict[str, Any]: + ctx = _context(root, station_key) + if not ctx["walls"]: + raise HTTPException(status_code=404, detail="그 측점에 벽이 없습니다.") + row = ctx["row"] + seismic = ctx["ground"].get("seismic") or {} + kh = ( + req.kh + if req.kh is not None + else float(seismic.get("kh") or 0.0) * bool(seismic.get("enabled")) + ) + params = {"method": req.method, "water_ratio": req.water_ratio, "kh": kh} + key = derived_key(code_hash(), row["fp"], "wall", params) + cached = read_derived(root, KIND, key) + if cached: + return cached["value"] + kw = {k: ctx["kw"][k] for k in ("materials", "layers", "ground", "road_surcharge_kpa", "walls")} + model = section_model_from_b06(ctx["cross"], ctx["design"], **kw) + mats = {m["id"]: m for m in model["materials"]} + roles = {s["side"]: s["role"] for s in row["sides"]} + walls = [] + for wall in ctx["walls"]: + poly = Polygon(wall["polygon"]) + side = _wall_side(wall) + role = roles.get(side, "fill") + outward = 1 if side == "left" else -1 + back = -outward if role == "fill" else outward # 쌓기 = 노면 쪽 흙 · 깎기 = 산 쪽 흙 + x0, z0, x1, z1 = poly.bounds + H = z1 - z0 + heel = x1 if back > 0 else x0 + toe = x0 if back > 0 else x1 + fill_id = _material_at(model, heel + back * 0.3, z0 + 0.5 * H) or "fill" + found_id = _material_at(model, 0.5 * (x0 + x1), z0 - 0.2) or fill_id + backfill, foundation = mats.get(fill_id, mats.get("fill")), mats.get(found_id) + surface = model["surface"] + dz = _surface_z(surface, heel + back * max(H, 1.0)) - _surface_z(surface, heel) + beta = math.degrees(math.atan2(dz, max(H, 1.0))) + beta = min(max(beta, 0.0), float(backfill["phi"]) - 1.0) + common = { + "backfill": backfill, + "foundation": foundation, + "method": req.method, + "beta_deg": beta, + "surcharge_kpa": ctx["kw"]["road_surcharge_kpa"] if role == "fill" else 0.0, + "water_ratio": req.water_ratio, + "wall_gamma": float(WALL_MATERIAL["gamma_t"]), + "embed_m": max(_surface_z(surface, toe - back * 0.05) - z0, 0.0), + } + cases = [wallcheck.check_wall(wall["polygon"], back, **common)] + if kh > 0: + cases.append(wallcheck.check_wall(wall["polygon"], back, kh=kh, **common)) + walls.append( + { + "name": wall.get("name"), + "kind": wall.get("kind"), + "side": side, + "role": role, + "backfill": fill_id, + "foundation": found_id, + "cases": cases, + } + ) + record = { + "type": "wall", + "station_key": station_key, + "chainage_m": row["chainage_m"], + "label": row.get("label"), + "base_fp": row["fp"], + "params": params, + "walls": walls, + "computed_at": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + write_derived(root, KIND, key, record, keep=KEEP) + return record + + +def records(root: Path) -> list[dict[str, Any]]: + """가장 최근 2D 요약 측점에 붙은 가정 · 벽 검토 — (측점 · 종류 · 비탈)마다 가장 최근 하나 · + 누가거리 순.""" + summary, _ = e03._latest_summary(root, "") + fps = {r["fp"] for r in (summary or {}).get("stations") or []} + latest: dict[tuple, dict[str, Any]] = {} + for path in derived_dir(root, KIND).glob("*.json"): + saved = read_derived(root, KIND, path.stem) + value = (saved or {}).get("value") + if not value or value.get("base_fp") not in fps: + continue + slot = (value["station_key"], value["type"], (value.get("assume") or {}).get("side")) + if slot not in latest or value["computed_at"] > latest[slot]["computed_at"]: + latest[slot] = value + return sorted(latest.values(), key=lambda v: (float(v["chainage_m"]), v["type"])) + + +@router.post("/{project_id}/stab/whatif/{station_key}", response_model=None) +async def post_whatif(project_id: UUID, station_key: str, payload: WhatIfRequest) -> dict: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(run_whatif, root, station_key, payload) + + +@router.post("/{project_id}/stab/wallcheck/{station_key}", response_model=None) +async def post_wallcheck(project_id: UUID, station_key: str, payload: WallRequest) -> dict: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(run_wallcheck, root, station_key, payload) + + +@router.get("/{project_id}/stab/whatif", response_model=None) +async def get_whatif(project_id: UUID) -> dict: + root, _ = await e02._project(project_id) + return {"records": await asyncio.to_thread(records, root)} diff --git a/E03_StabSection/E03_StabSection_Types.py b/E03_StabSection/E03_StabSection_Types.py new file mode 100644 index 000000000..e4763828c --- /dev/null +++ b/E03_StabSection/E03_StabSection_Types.py @@ -0,0 +1,89 @@ +"""E 안정성평가 공용 꼴 — 2D(E03) · 3D(E04) 해석이 같은 입력 꼴을 씀. + +단위: 단위중량 kN/m³ · 점착력 · 하중 kPa · 내부마찰각 ° · 좌표 m. +E04 가 이 파일을 그대로 import — 이름 · 열쇠를 바꾸지 않음. +""" + +import json +from pathlib import Path +from typing import Literal, TypedDict + +GAMMA_W = 9.81 # 물 단위중량 kN/m³ + + +class Material(TypedDict): + id: str + name: str + gamma_t: float # 습윤 단위중량 (지하수위 위) + gamma_sat: float # 포화 단위중량 (지하수위 아래) + c: float # 유효 점착력 + phi: float # 유효 내부마찰각 + + +class Groundwater(TypedDict, total=False): + mode: Literal["none", "ru", "line"] + ru: float # mode="ru" — 간극수압비 u = ru · γ·h + line: list[list[float]] # mode="line" — 지하수위선 [[x, z], …] (2D) · x 오름차순 + + +class Surcharge(TypedDict): + x0: float + x1: float + q: float # 연직 등분포 kPa + + +def material(raw: dict) -> Material: + """밖에서 온 물성 한 줄을 꼴에 맞춤 — 빠진 γsat 는 γt · 음수 · 범위 밖은 오류.""" + gamma_t = float(raw["gamma_t"]) + gamma_sat = float(raw.get("gamma_sat", gamma_t)) + c = float(raw["c"]) + phi = float(raw["phi"]) + if gamma_t <= 0 or gamma_sat <= 0 or c < 0 or not 0 <= phi < 90: + raise ValueError(f"물성 범위 밖: {raw}") + return { + "id": str(raw["id"]), + "name": str(raw.get("name", raw["id"])), + "gamma_t": gamma_t, + "gamma_sat": gamma_sat, + "c": c, + "phi": phi, + } + + +def groundwater(raw: dict | None) -> Groundwater: + """지하수 꼴 맞춤 — 없으면 none · line 은 x 오름차순으로 정렬.""" + if not raw or raw.get("mode", "none") == "none": + return {"mode": "none"} + mode = raw["mode"] + if mode == "ru": + ru = float(raw.get("ru", 0.0)) + if not 0 <= ru < 1: + raise ValueError(f"r_u 범위 밖: {ru}") + return {"mode": "ru", "ru": ru} + if mode == "line": + line = sorted([float(x), float(z)] for x, z in raw["line"]) + if len(line) < 2: + raise ValueError("지하수위선 점이 둘 미만") + return {"mode": "line", "line": line} + raise ValueError(f"지하수 mode 모름: {mode}") + + +def surcharges(raw: list[dict] | None) -> list[Surcharge]: + """상재하중 꼴 맞춤 — x0 < x1 로 정렬 · 길이 0 은 버림.""" + out: list[Surcharge] = [] + for item in raw or []: + x0, x1 = sorted((float(item["x0"]), float(item["x1"]))) + if x1 > x0: + out.append({"x0": x0, "x1": x1, "q": float(item["q"])}) + return out + + +# 기본 물성 · 노면 하중 = M02 마스터 층 `ground_defaults.json`(E02 초기값과 한 벌 · 사용자가 E02 에서 바꿈). +_DEFAULTS = json.loads( + ( + Path(__file__).resolve().parents[1] + / "resources/master_template/stability/ground_defaults.json" + ).read_text(encoding="utf-8") +) +DEFAULT_MATERIALS: list[Material] = [material(m) for m in _DEFAULTS["materials"]] +DEFAULT_ROAD_SURCHARGE_KPA = float(_DEFAULTS["road_surcharge_kpa"]) # 노면 하중 기본 diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts new file mode 100644 index 000000000..6a810878a --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * E03_StabSection_UI_List.ts + * E03 좌측 측점 목록(PLAN 532-3) — 측점 · 깎기/쌓기 · 조건별 최소 안전율 · 판정(아이콘 + 글) · + * 대표 단면 별(자동 = 채움 · 사용자 켬/끔 = 밑줄 · 누르면 바꿈). 정렬 · 거름은 logic `listRows`. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { + listRows, + roleText, + verdictOf, + type Condition, + type SortMode, + type StationRow, +} from "./logic/E03_StabSection_Logic"; + +const fmt = (v: number | null | undefined): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(2); + +export interface ListDraw { + rows: StationRow[]; + conditions: Condition[]; + condition: Condition; + sort: SortMode; + ngOnly: boolean; + active: string | null; + /** 사용자 켬/끔 — 저장 전 캐시 포함 · 없으면 서버 값 */ + representative: (row: StationRow) => { on: boolean; user: boolean | null }; + editable: boolean; +} + +export function buildList( + draw: ListDraw, + onSelect: (key: string) => void, + onToggle: (key: string) => void, +): HTMLElement { + const box = el("div", { className: "e03-list" }); + const table = el("table", { attrs: { "data-role": "station-list" } }); + const head = el("tr"); + head.append( + el("th", { text: t("E03_Col_Station") }), + el("th", { text: t("E03_Col_Role") }), + ...draw.conditions.map((c) => + el("th", { + className: "is-num", + text: t(`E03_Cond_${c}` as LocaleKey), + attrs: c === draw.condition ? { "aria-sort": "other" } : {}, + }), + ), + el("th", { text: t("E03_Verdict") }), + el("th", { text: t("E03_Col_Rep"), attrs: { title: t("E03_Rep_Hint") } }), + ); + table.append(el("thead", { children: [head] })); + const body = el("tbody"); + const rows = listRows(draw.rows, draw.condition, draw.sort, draw.ngOnly); + for (const row of rows) { + const tr = el("tr", { + className: row.key === draw.active ? "is-active" : "", + attrs: { "data-key": row.key, "data-verdict": verdictOf(row, draw.condition) }, + }); + const v = verdictOf(row, draw.condition); + const rep = draw.representative(row); + const star = el("button", { + className: `e03-star${rep.on ? " is-on" : ""}${rep.user !== null ? " is-user" : ""}`, + text: rep.on ? "★" : "☆", + attrs: { + type: "button", + title: + [ + ...(row.representative?.auto ?? []).map((r) => t(`E03_Rep_${r}` as LocaleKey)), + rep.user !== null ? t("E03_Rep_User") : "", + ] + .filter(Boolean) + .join(" · ") || t("E03_Rep_Hint"), + "aria-pressed": String(rep.on), + }, + }); + star.disabled = !draw.editable; + star.addEventListener("click", (ev) => { + ev.stopPropagation(); + onToggle(row.key); + }); + tr.append( + el("td", { + text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key, + children: row.walls + ? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })] + : [], + }), + el("td", { + text: row.sides.length + ? roleText(row, { cut: t("E03_CutShort"), fill: t("E03_FillShort") }) + : "-", + }), + ...draw.conditions.map((c) => el("td", { className: "is-num", text: fmt(row.min[c]) })), + el("td", { + children: [ + el("span", { + className: `e03-verdict e03-verdict--${v}`, + text: row.error + ? t("E03_Error") + : v === "none" + ? "-" + : t(`E03_Verdict_${v}` as LocaleKey), + }), + ], + }), + el("td", { children: [star] }), + ); + if (row.error) tr.title = row.error; + tr.addEventListener("click", () => onSelect(row.key)); + body.append(tr); + } + table.append(body); + box.append(table); + if (!rows.length) box.append(el("p", { className: "e03-empty", text: t("E03_ListEmpty") })); + queueMicrotask(() => box.querySelector("tr.is-active")?.scrollIntoView({ block: "nearest" })); + return box; +} diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts new file mode 100644 index 000000000..e458e70f4 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -0,0 +1,539 @@ +/* ============================================================================= + * E03_StabSection_UI_Page.ts + * E03 2D 측점 해석(요구서 345 4-3 · 요구 347 · 351 · PLAN 532-3) — B05~B09 틀(group_workflow). + * 좌 = 측점 목록(조건 · 정렬 · N.G 만 · 대표 단면 별) · [해석] · [3D 보기] · [대표 단면 저장] + * 위 = 평면 위험 띠 · 가운데 = 횡단(임계면 · 등안전율 · 후보 · 절편) · 우 = 결과 · 절편표 · 자유물체도 · 그래프 + * 계산 = 서버 단독([해석] → POST …/stab/analyze2d · 잠금판 진행률 = GET …/stab/progress) · + * 대표 단면 켬/끔 = sessionStorage 캐시 → [대표 단면 저장] = PUT(자동저장 없음). + * 「보강 검토」 탭 = 우측을 가정 재해석 · 벽 외적 안정으로 바꿈(`UI_Reinforce` · 서버 따로 저장본). + * 「확률 · 민감도」 탭 = 대표 단면 확률 해석(`UI_Prob` · 서버 따로 저장본 · 카드 안 진행률). + * ========================================================================== */ + +import "./E03_StabSection_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { + createButton, + createSelectField, + el, + hideLoadingOverlay, + setLoadingOverlayProgress, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; +import { buildList } from "./E03_StabSection_UI_List"; +import { buildPlan } from "./E03_StabSection_UI_Plan"; +import { buildResult } from "./E03_StabSection_UI_Result"; +import { + buildLegends, + createSectionCanvas, + WALL_ID, + type SectionToggles, +} from "./E03_StabSection_UI_Section"; +import { openView3d } from "./E03_StabSection_UI_View3D"; +import { + buildReinforce, + type SlopeAsk, + type WallAsk, + type WallRecord, + type WhatIfRecord, +} from "./E03_StabSection_UI_Reinforce"; +import { buildSupport, type SupportAsk, type SupportRecord } from "./E03_StabSection_UI_Support"; +import { createProbPanel } from "./E03_StabSection_UI_Prob"; +import type { + Analysis, + Condition, + SectionView, + SortMode, + StationRow, +} from "./logic/E03_StabSection_Logic"; + +interface Result2d { + status: "ok" | "none"; + fresh: boolean; + editable: boolean; + running: boolean; + conditions: Condition[]; + computed_at?: string; + summary?: Record; + stations: StationRow[]; + /** 횡단 없어 해석에서 빠진 측점 누가거리(E01 복사본) */ + missing_stations?: number[]; +} + +interface Progress { + running: boolean; + percent: number; + message: string; + error: string | null; +} + +async function api(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 T & { detail?: unknown }; + if (!response.ok) { + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + return body; +} + +const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`; +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +function checkbox(label: string, checked: boolean, onChange: (v: boolean) => void): HTMLElement { + const input = el("input", { attrs: { type: "checkbox" } }); + input.checked = checked; + input.addEventListener("change", () => onChange(input.checked)); + return el("label", { className: "e03-check", children: [input, label] }); +} + +export function renderE03StabSection(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const main = el("div", { className: "e03-page" }); + const panel = el("div", { className: "ui-sidebar-panel e03-panel" }); + mountGroupStep(root, ROUTES.E03_STAB_SECTION, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e03-empty", text: t("E03_NoProject") })); + const pid: string = projectId; + const base = `/projects/${pid}/stab`; + const prob = createProbPanel(pid, base, () => paintSection()); + + let result: Result2d | null = null; + const sections = new Map(); + let condition: Condition = "dry"; + let sort: SortMode = "chainage"; + let ngOnly = false; + let active: string | null = null; + let side: string | null = null; + let picked: number | null = null; + const toggles: SectionToggles = { map: true, centers: false, candidates: true }; + let mode: "result" | "reinforce" | "prob" = "result"; + let busy = false; + const slopeRecs = new Map(); // 측점 열쇠 → 마지막 가정 + const wallRecs = new Map(); + const supportRecs = new Map(); + let repCache: Record = JSON.parse( + sessionStorage.getItem(repKey(pid)) ?? "{}", + ); + + // ---- 좌측 ---- + const condField = createSelectField({ + label: t("E03_Condition"), + options: [], + compact: true, + onChange: (v) => { + condition = v as Condition; + side = null; + picked = null; + paint(); + }, + }); + const sortField = createSelectField({ + label: t("E03_Sort"), + options: [ + { value: "chainage", text: t("E03_Sort_Chainage") }, + { value: "fs", text: t("E03_Sort_Fs") }, + ], + compact: true, + onChange: (v) => { + sort = v as SortMode; + paintList(); + }, + }); + const ngBox = checkbox(t("E03_NgOnly"), false, (v) => { + ngOnly = v; + paintList(); + }); + const status = el("p", { className: "e03-muted", attrs: { "data-role": "status" } }); + const listHost = el("div", { + className: "e03-panel", + attrs: { style: "flex: 1 1 auto; min-height: 0" }, + }); + const analyzeButton = createButton({ + label: t("E03_Analyze"), + variant: "filled", + onClick: () => void analyze(), + }); + const view3dButton = createButton({ + label: t("E03_View3d"), + variant: "ghost", + onClick: () => void show3d(), + }); + const repButton = createButton({ + label: t("E03_RepSave"), + variant: "ghost", + onClick: () => void saveRep(), + }); + const controls = el("div", { className: "e03-panel__row" }); + controls.append(condField.root, sortField.root); + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(repButton, view3dButton, analyzeButton); + panel.append(controls, ngBox, status, listHost, actions); + + // ---- 본문 ---- + const missingHost = el("div"); + const planHost = el("div"); + const toolbar = el("div", { className: "e03-card__head" }); + const sectionCard = el("div", { className: "e03-card" }); + const legendHost = el("div"); + const canvas = createSectionCanvas((i) => { + picked = i; + paintSection(); + }); + sectionCard.append(toolbar, canvas.root, legendHost); + const resultHost = el("div", { className: "e03-result", attrs: { style: "min-height: 0" } }); + const body = el("div", { className: "e03-body" }); + body.append(sectionCard, resultHost); + main.append(missingHost, planHost, body); + + const repOf = (row: StationRow): { on: boolean; user: boolean | null } => { + const server = row.representative ?? { auto: [], user: null, on: false }; + if (!(row.key in repCache)) return { on: server.on, user: server.user }; + const user = repCache[row.key]; + return { on: user === null ? server.auto.length > 0 : user, user }; + }; + const repDirty = (): boolean => Object.keys(repCache).length > 0; + + const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active); + + /** 고른 측점 · 조건의 비탈 — 고른 게 없으면 안전율 가장 낮은 쪽 */ + const currentAnalysis = (view: SectionView): Analysis | null => { + const list = view.analyses.filter((a) => a.condition === condition); + if (!list.length) return null; + const found = side ? list.find((a) => a.side === side) : null; + if (found) return found; + const worst = list + .slice() + .sort((a, b) => (a.critical?.fs ?? Infinity) - (b.critical?.fs ?? Infinity))[0]; + side = worst.side; + return worst; + }; + + const paintStatus = (): void => { + const lines: string[] = []; + if (!result) lines.push(t("E03_Loading")); + else if (result.status === "none") lines.push(t("E03_NotYet")); + else { + lines.push(`${t("E03_Computed")} ${result.computed_at ?? ""}`); + if (!result.fresh) lines.push(t("E03_Stale")); + } + if (result && !result.editable) lines.push(t("E03_ReadOnly")); + if (repDirty()) lines.push(t("E03_RepUnsaved")); + status.textContent = lines.join("\n"); + analyzeButton.disabled = !result?.editable; + repButton.disabled = !result?.editable || !repDirty(); + view3dButton.disabled = result?.status !== "ok"; + }; + + const paintList = (): void => { + if (!result) return; + listHost.replaceChildren( + buildList( + { + rows: result.stations, + conditions: result.conditions, + condition, + sort, + ngOnly, + active, + representative: repOf, + editable: result.editable, + }, + (key) => void select(key), + (key) => { + const row = result!.stations.find((r) => r.key === key)!; + const now = repOf(row).on; + const auto = (row.representative?.auto.length ?? 0) > 0; + // 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌 + const next = !now === auto ? null : !now; + if (next === (row.representative?.user ?? null)) delete repCache[key]; + else repCache[key] = next; + sessionStorage.setItem(repKey(pid), JSON.stringify(repCache)); + paintList(); + paintStatus(); + }, + ), + ); + }; + + const paintSection = (): void => { + const view = active ? sections.get(active) : undefined; + toolbar.replaceChildren(); + resultHost.replaceChildren(); + legendHost.replaceChildren(); + if (!view) { + canvas.render(null); + const text = result?.status === "ok" ? t("E03_PickStation") : t("E03_NotYet"); + resultHost.append( + el("div", { className: "e03-card", children: [el("p", { className: "e03-empty", text })] }), + ); + return; + } + const analysis = currentAnalysis(view); + const sides = view.analyses.filter((a) => a.condition === condition); + const sideField = createSelectField({ + options: sides.map((a) => ({ + value: a.side, + text: `${t(a.side === "left" ? "E03_Left" : "E03_Right")} · ${t(a.role === "cut" ? "E03_Cut" : "E03_Fill")}`, + })), + value: analysis?.side, + compact: true, + ariaLabel: t("E03_Side"), + onChange: (v) => { + side = v; + picked = null; + paintSection(); + }, + }); + const title = el("h3", { + className: "e03-card__title", + text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`, + }); + const flags = el("div", { className: "e03-panel__row" }); + flags.append( + checkbox(t("E03_Show_Map"), toggles.map, (v) => ((toggles.map = v), paintSection())), + checkbox( + t("E03_Show_Centers"), + toggles.centers, + (v) => ((toggles.centers = v), paintSection()), + ), + checkbox( + t("E03_Show_Candidates"), + toggles.candidates, + (v) => ((toggles.candidates = v), paintSection()), + ), + ); + const modeField = createSelectField({ + options: [ + { value: "result", text: t("E03_Tab_Result") }, + { value: "reinforce", text: t("E03_Tab_Reinforce") }, + { value: "prob", text: t("E03_Tab_Prob") }, + ], + value: mode, + compact: true, + ariaLabel: t("E03_Tab"), + onChange: (v) => { + mode = v as typeof mode; + paintSection(); + }, + }); + modeField.select.dataset.role = "mode"; + toolbar.append(title, sideField.root, modeField.root, flags); + const draw = { view, analysis, toggles, picked }; + canvas.render(draw); + legendHost.append(buildLegends(draw)); + const rule = view.water_rule?.[condition]; + if (rule?.mode === "ru") + legendHost.append( + el("p", { + className: "e03-muted", + text: t("E03_Water_Ru").replace("{v}", String(rule.ru)), + attrs: { "data-role": "water-ru" }, + }), + ); + if (view.regions.some((r) => r.material_id === WALL_ID)) + legendHost.append(el("p", { className: "e03-muted", text: t("E03_Walls_Hint") })); + const row = currentRow(); + if (mode === "prob" && row) { + resultHost.append( + prob.render({ row, condition, side, editable: !!result?.editable && !busy }), + ); + return; + } + if (mode === "reinforce" && row) { + if (row.walls && !wallRecs.has(row.key) && !busy && result?.editable) + queueMicrotask(() => void runWall(row.key, { method: "coulomb", water_ratio: 0 })); + resultHost.append( + buildReinforce({ + row, + condition, + side, + editable: !!result?.editable, + busy, + slope: slopeRecs.get(row.key), + wall: wallRecs.get(row.key), + runSlope: (ask) => void runSlope(row.key, ask), + runWall: (ask) => void runWall(row.key, ask), + }), + buildSupport({ + row, + view, + condition, + side, + editable: !!result?.editable, + busy, + record: supportRecs.get(row.key), + run: (ask) => void runSupport(row.key, ask), + }), + ); + return; + } + resultHost.append( + buildResult(view, analysis, picked, (i) => { + picked = i; + paintSection(); + }), + ); + }; + + /** 가정 재해석 · 벽 검토 — 서버 계산(수십 초) 동안 단추만 잠금 */ + async function reinforce( + path: string, + body: unknown, + done: (value: T) => void, + ): Promise { + busy = true; + paintSection(); + try { + done(await api(path, { method: "POST", body: JSON.stringify(body) })); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + busy = false; + paintSection(); + } + } + const runSlope = (key: string, ask: SlopeAsk): Promise => + reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => { + slopeRecs.set(key, rec); + side = rec.assume.side; // 전 그림 = 가정한 비탈 + }); + const runSupport = (key: string, ask: SupportAsk): Promise => + reinforce(`${base}/whatif/${encodeURIComponent(key)}`, ask, (rec) => { + supportRecs.set(key, rec); + side = rec.assume.side; + }); + const runWall = (key: string, ask: WallAsk): Promise => + reinforce(`${base}/wallcheck/${encodeURIComponent(key)}`, ask, (rec) => + wallRecs.set(key, rec), + ); + + const paintPlan = (): void => { + planHost.replaceChildren( + result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"), + ); + }; + + function paint(): void { + paintStatus(); + paintList(); + paintPlan(); + paintSection(); + } + + async function select(key: string): Promise { + if (active !== key) picked = null; + active = key; + paintList(); + paintPlan(); + if (result?.status === "ok" && !sections.has(key) && !currentRow()?.error) { + try { + sections.set(key, await api(`${base}/section/${encodeURIComponent(key)}`)); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + } + if (active === key) paintSection(); + } + + async function load(): Promise { + const before = result?.status; + result = await api(`${base}/result2d`); + if (before === "none" && result.status === "ok") active = null; // 첫 해석 뒤 = 가장 낮은 측점으로 + sections.clear(); + missingHost.replaceChildren(buildMissingBanner(result.missing_stations)); + const conds = result.conditions; + if (!conds.includes(condition)) condition = conds[0] ?? "dry"; + condField.setOptions( + conds.map((c) => ({ value: c, text: t(`E03_Cond_${c}` as LocaleKey) })), + condition, + ); + const keys = result.stations.map((r) => r.key); + repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k))); + if (!active || !keys.includes(active)) { + // 처음 = 그 조건 가장 낮은 안전율 측점 + const worst = result.stations + .filter((r) => r.min[condition] !== null && r.min[condition] !== undefined) + .sort((a, b) => (a.min[condition] as number) - (b.min[condition] as number))[0]; + active = worst?.key ?? keys[0] ?? null; + } + paint(); + if (active) await select(active); + if (result.running) await watch(); + } + + /** 잠금판 + 진행률 — 끝나면 다시 읽음 */ + async function watch(): Promise { + showLoadingOverlay(); + try { + for (;;) { + const p = await api(`${base}/progress`); + setLoadingOverlayProgress( + p.percent / 100, + `${t("E03_Analyzing")} ${p.percent}% · ${p.message}`, + ); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else showToast(t("E03_Done"), "success"); + break; + } + await sleep(1000); + } + } finally { + hideLoadingOverlay(); + } + await load(); + } + + async function analyze(): Promise { + try { + const response = await api<{ status: string }>(`${base}/analyze2d`, { method: "POST" }); + if (response.status === "fresh") { + showToast(t("E03_Fresh"), "info"); + await load(); + return; + } + await watch(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + } + + async function saveRep(): Promise { + try { + const overrides: Record = {}; + for (const row of result?.stations ?? []) { + const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null); + if (user !== null) overrides[row.key] = user; + } + await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) }); + repCache = {}; + sessionStorage.removeItem(repKey(pid)); + showToast(t("E03_RepSaved"), "success"); + await load(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + } + + async function show3d(): Promise { + if (!result) return; + try { + const data = await api[0]>(`${base}/corridor3d`); + await openView3d(data, result.stations, condition, t(`E03_Cond_${condition}` as LocaleKey)); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + } + + paintStatus(); + load().catch((error) => { + status.textContent = error instanceof Error ? error.message : t("Common_Status_Error"); + }); +} diff --git a/E03_StabSection/E03_StabSection_UI_Plan.ts b/E03_StabSection/E03_StabSection_UI_Plan.ts new file mode 100644 index 000000000..c732c2104 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Plan.ts @@ -0,0 +1,89 @@ +/* ============================================================================= + * E03_StabSection_UI_Plan.ts + * E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이 + * 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점. + * 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { + bandSegments, + planFit, + verdictOf, + type Condition, + type Point, + type StationRow, +} from "./logic/E03_StabSection_Logic"; +import { svgEl } from "./E03_StabSection_UI_Section"; + +const H = 96; +const PAD = 10; + +export function buildPlan( + rows: StationRow[], + condition: Condition, + active: string | null, + onSelect: (key: string) => void, +): HTMLElement { + const box = el("div", { className: "e03-plan e03-card" }); + const head = el("div", { className: "e03-card__head" }); + head.append(el("h3", { className: "e03-card__title", text: t("E03_Plan") })); + const legend = el("div", { className: "e03-legend", attrs: { "data-role": "plan-legend" } }); + const counts = { ok: 0, ng: 0, none: 0 }; + rows.forEach((r) => (counts[verdictOf(r, condition)] += 1)); + for (const v of ["ng", "ok", "none"] as const) { + if (!counts[v]) continue; + const item = el("span", { className: `e03-verdict e03-verdict--${v}` }); + item.append(`${t(`E03_Verdict_${v}` as LocaleKey)} ${counts[v]}`); + legend.append(item); + } + head.append(legend); + box.append(head); + + const host = el("div"); + box.append(host); + const draw = (): void => { + const W = Math.max(host.clientWidth, 300); + const segs = bandSegments(rows, condition); + const all: Point[] = segs.flatMap((s) => s.points); + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" }); + if (all.length) { + const fit = planFit(all, W, H, PAD); + const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; + for (const seg of segs) { + svg.append( + svgEl("polyline", { + points: seg.points.map((p) => scr(p).join(",")).join(" "), + class: `e03-plan__seg e03-plan__seg--${seg.verdict}`, + "data-verdict": seg.verdict, + }), + ); + } + const centers = rows.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null); + const picked = centers.find((r) => r.key === active); + if (picked) { + const [x, y] = scr([picked.center![0]!, picked.center![1]!]); + svg.append( + svgEl("circle", { cx: x, cy: y, r: 6, class: "e03-plan__pick", "data-key": picked.key }), + ); + } + svg.addEventListener("click", (ev) => { + const box2 = svg.getBoundingClientRect(); + const px = ((ev.clientX - box2.left) / box2.width) * W; + const py = ((ev.clientY - box2.top) / box2.height) * H; + let best: StationRow | null = null; + let dist = Infinity; + for (const r of centers) { + const [x, y] = scr([r.center![0]!, r.center![1]!]); + const d = Math.hypot(x - px, y - py); + if (d < dist) [best, dist] = [r, d]; + } + if (best && dist < 30) onSelect(best.key); + }); + } + host.replaceChildren(svg); + }; + new ResizeObserver(draw).observe(host); + return box; +} diff --git a/E03_StabSection/E03_StabSection_UI_Prob.css b/E03_StabSection/E03_StabSection_UI_Prob.css new file mode 100644 index 000000000..0a9f45842 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Prob.css @@ -0,0 +1,116 @@ +/* E03 확률 · 민감도 탭(537-1) — 입력 표 · 히스토그램 · 민감도 선 · 토네이도 막대 */ +.e03-prob-vars { + font-size: 12px; +} + +.e03-prob-vars input[type="number"] { + width: 4.2em; +} + +.e03-prob-vars select { + width: 6.2em; +} + +.e03-prob-run { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px 8px; + margin-top: 8px; +} + +.e03-prob-run .ui-field { + min-width: 0; +} + +.e03-prob-run select, +.e03-prob-run input { + width: 100%; +} + +.e03-prob-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin: 8px 0; +} + +.e03-prob-src summary { + cursor: pointer; + color: var(--color-text-secondary); +} + +.e03-prob-bar { + fill: var(--e03-mat-0); + fill-opacity: 0.75; +} + +.e03-prob-bar--fail { + fill: var(--e03-ng); +} + +.e03-prob-mark { + stroke-width: 1.5; +} + +.e03-prob-mark--one { + stroke: var(--e03-ng); +} + +.e03-prob-mark--req { + stroke: var(--e03-mat-3); + stroke-dasharray: 5 3; +} + +.e03-prob-mark--det { + stroke: var(--e03-critical); + stroke-dasharray: 2 2; +} + +.e03-prob-mark__text { + font-size: 10px; + stroke: none; + fill: var(--color-text-secondary); +} + +.e03-prob-line { + fill: none; + stroke-width: 2; +} + +.e03-prob-line--0 { + stroke: var(--e03-mat-0); +} +.e03-prob-line--1 { + stroke: var(--e03-mat-1); +} +.e03-prob-line--2 { + stroke: var(--e03-mat-2); +} +.e03-prob-line--3 { + stroke: var(--e03-mat-3); +} +.e03-prob-line--4 { + stroke: var(--e03-mat-4); +} +.e03-prob-line--5 { + stroke: var(--e03-mat-5); +} +.e03-prob-line--6 { + stroke: var(--e03-mat-6); +} +.e03-prob-line--7 { + stroke: var(--e03-mat-7); +} + +.e03-prob-tbar--lo { + fill: var(--e03-mat-1); +} + +.e03-prob-tbar--hi { + fill: var(--e03-mat-0); +} + +.e03-prob-tlabel { + font-size: 10px; + fill: var(--color-text-secondary); +} diff --git a/E03_StabSection/E03_StabSection_UI_Prob.ts b/E03_StabSection/E03_StabSection_UI_Prob.ts new file mode 100644 index 000000000..d51ce5f5a --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Prob.ts @@ -0,0 +1,385 @@ +/* ============================================================================= + * E03_StabSection_UI_Prob.ts + * E03 「확률 · 민감도」 탭(요구 351 · PLAN 537-1) — 대표 단면 · 고른 비탈 · 조건. + * 분포 입력(단면 물성 × c · φ · γ · 지하수위 · r_u — 쓰기 · 분포 · 변동계수/표준편차 · 절단 ±kσ) · + * 표본(LHS · MC · N · 시드) · 갈래(임계면 고정 · 매회 탐색) · 방법 · c–φ 상관 → + * 서버 계산(POST …/stab/prob/{측점} · 뒤에서 · 진행 GET …/stab/prob/progress · 카드 안 진행률). + * 기본값 후보 = 서버 `prob_defaults.json`(근거 · 사용자 확정 전) · 입력 = sessionStorage 캐시(자동저장 없음). + * 결과 = 요약 표 · 히스토그램 · 누적분포 · 민감도 · 토네이도(`UI_ProbChart`). + * ========================================================================== */ + +import "./E03_StabSection_UI_Prob.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { + changedCount, + fillCache, + probRequest, + probRows, + type ProbCache, + type ProbRecord, + type ProbRow, + type ProbSetup, +} from "./logic/E03_StabSection_Prob"; +import type { Condition, StationRow } from "./logic/E03_StabSection_Logic"; +import { + cdfChart, + histogramChart, + sensitivityChart, + tornadoChart, +} from "./E03_StabSection_UI_ProbChart"; + +export interface ProbView { + row: StationRow; + condition: Condition; + side: string | null; + editable: boolean; +} + +interface Progress { + running: boolean; + percent: number; + message: string; + error: string | null; +} + +const fmt = (v: number | null | undefined, d = 3): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); +const METHODS = ["bishop", "janbu", "spencer", "morgenstern_price", "ordinary"] as const; +const methodName = (m: string): string => + t(m === "morgenstern_price" ? "E03_Pb_Method_mp" : (`E03_Method_${m}` as LocaleKey)); + +async function api(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 T & { detail?: unknown }; + if (!response.ok) + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + return body; +} + +/** 탭 하나 — 준비(setup) · 결과 · 진행을 들고 다시 그릴 때 `repaint` 를 부름 */ +export function createProbPanel(projectId: string, base: string, repaint: () => void) { + const cacheKey = `e03_prob_cache:${projectId}`; + const setups = new Map(); + const records = new Map(); + let cache: ProbCache | null = JSON.parse(sessionStorage.getItem(cacheKey) ?? "null"); + let busy: Progress | null = null; + + const save = (): void => sessionStorage.setItem(cacheKey, JSON.stringify(cache)); + const slotOf = (v: ProbView, side: string): string => `${v.row.key}|${side}|${v.condition}`; + + async function load(slot: string, key: string, side: string, condition: string): Promise { + setups.set(slot, "loading"); + try { + const q = `side=${side}&condition=${condition}`; + const s = await api(`${base}/prob/setup/${encodeURIComponent(key)}?${q}`); + setups.set(slot, s); + if (s.last) records.set(slot, s.last); + } catch (error) { + setups.set(slot, error instanceof Error ? error.message : t("Common_Status_Error")); + } + repaint(); + } + + async function run(slot: string, setup: ProbSetup): Promise { + if (!cache) return; + busy = { running: true, percent: 0, message: "", error: null }; + repaint(); + try { + const body = probRequest(setup, cache); + const path = `${base}/prob/${encodeURIComponent(setup.station_key)}`; + const first = await api<{ status: string; record?: ProbRecord }>(path, { + method: "POST", + body: JSON.stringify(body), + }); + if (first.status !== "fresh") { + for (;;) { + await sleep(700); + busy = await api(`${base}/prob/progress`); + repaint(); + if (!busy.running) break; + } + if (busy.error) throw new Error(busy.error); + } + busy = null; + await load(slot, setup.station_key, setup.side, setup.condition); + showToast(t("E03_Pb_Done"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + busy = null; + repaint(); + } + } + + function inputTable(setup: ProbSetup): HTMLElement { + const table = el("table", { + className: "e03-table e03-prob-vars", + attrs: { "data-role": "prob-vars" }, + }); + const heads = [ + "E03_Pb_Use", + "E03_Pb_Var", + "E03_Pb_Mean", + "E03_Pb_Dist", + "E03_Pb_Spread", + "E03_Pb_Trunc", + ]; + table.append( + el("thead", { + children: [el("tr", { children: heads.map((k) => el("th", { text: t(k as LocaleKey) })) })], + }), + ); + const body = el("tbody"); + for (const row of probRows(setup)) body.append(inputRow(row)); + table.append(body); + return table; + } + + function inputRow(row: ProbRow): HTMLElement { + const v = cache!.vars[row.key]; + const tr = el("tr", { attrs: { "data-key": row.key } }); + const use = el("input", { attrs: { type: "checkbox", "data-field": "use" } }); + use.checked = v.use; + use.addEventListener("change", () => ((v.use = use.checked), save())); + const dist = el("select", { + className: "ui-input ui-input--sm", + attrs: { "data-field": "dist" }, + }); + for (const d of row.prop === "water" ? ["normal"] : ["normal", "lognormal"]) + dist.append(el("option", { text: t(`E03_Pb_Dist_${d}` as LocaleKey), attrs: { value: d } })); + dist.value = v.dist; + dist.addEventListener("change", () => ((v.dist = dist.value as typeof v.dist), save())); + const num = (value: number | null, field: string, apply: (n: number | null) => void) => { + const input = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "number", step: "0.01", min: "0", "data-field": field }, + }); + input.value = value === null ? "" : String(value); + input.addEventListener("input", () => { + const n = Number(input.value); + if (input.value.trim() === "") apply(null); + else if (Number.isFinite(n) && n >= 0) apply(n); + save(); + }); + return input; + }; + const unit = row.kind === "cov" ? "" : row.prop === "water" ? "σ m" : "σ"; + const label = + row.prop === "water" || row.prop === "ru" + ? t(`E03_Pb_Prop_${row.prop}` as LocaleKey) + : `${row.name} ${t(`E03_Pb_Prop_${row.prop}` as LocaleKey)}`; + tr.append( + el("td", { children: [use] }), + el("td", { text: label }), + el("td", { className: "is-num", text: row.prop === "water" ? "±0" : fmt(row.mean, 2) }), + el("td", { children: [dist] }), + el("td", { + children: [ + num(v.value, "value", (n) => (v.value = n ?? 0)), + el("span", { className: "e03-muted", text: ` ${unit}` }), + ], + }), + el("td", { children: [num(v.trunc, "trunc", (n) => (v.trunc = n))] }), + ); + return tr; + } + + function runFields(setup: ProbSetup, slot: string, editable: boolean): HTMLElement { + const r = cache!.run; + const wrap = el("div", { className: "e03-prob-run" }); + const set = (k: K, v: (typeof r)[K]): void => { + r[k] = v; + save(); + }; + const sel = ( + label: LocaleKey, + value: string, + opts: [string, string][], + on: (v: string) => void, + ) => { + const f = createSelectField({ + label: t(label), + options: opts.map(([value, text]) => ({ value, text })), + value, + compact: true, + onChange: on, + }); + f.select.dataset.field = label; + return f.root; + }; + const numField = (label: LocaleKey, value: number, step: string, on: (n: number) => void) => { + const f = createInputField({ + label: t(label), + type: "number", + compact: true, + value: String(value), + }); + f.input.step = step; + f.input.dataset.field = label; + f.input.addEventListener("input", () => { + const n = Number(f.input.value); + if (f.input.value.trim() !== "" && Number.isFinite(n)) on(n); + }); + return f.root; + }; + wrap.append( + sel( + "E03_Pb_Mode", + r.mode, + [ + ["fixed", t("E03_Pb_Mode_fixed")], + ["search", t("E03_Pb_Mode_search")], + ], + (v) => set("mode", v as typeof r.mode), + ), + sel( + "E03_Pb_Method", + r.method, + METHODS.map((m) => [m, methodName(m)]), + (v) => set("method", v as typeof r.method), + ), + sel( + "E03_Pb_Sampling", + r.sampling, + [ + ["lhs", "LHS"], + ["mc", "Monte Carlo"], + ], + (v) => set("sampling", v as typeof r.sampling), + ), + numField("E03_Pb_N", r.n, "100", (n) => set("n", n)), + numField("E03_Pb_Seed", r.seed, "1", (n) => set("seed", n)), + numField("E03_Pb_Rho", r.rho, "0.1", (n) => set("rho", n)), + ); + const go = createButton({ + label: busy ? `${t("E03_Rf_Busy")} ${busy.percent}%` : t("E03_Pb_Run"), + variant: "filled", + onClick: () => void run(slot, setup), + }); + go.dataset.role = "prob-run"; + go.disabled = !editable || !!busy; + const reset = createButton({ + label: t("E03_Pb_Defaults"), + variant: "ghost", + onClick: () => { + cache = fillCache(setup, null); + save(); + repaint(); + }, + }); + reset.disabled = !!busy; + const actions = el("div", { className: "e03-prob-actions" }); + actions.append(reset, go); + return el("div", { children: [wrap, actions] }); + } + + function summary(rec: ProbRecord): HTMLElement { + const s = rec.result.stats; + const rows: [LocaleKey, string][] = [ + ["E03_Pb_Det", fmt(rec.fs_deterministic)], + ["E03_Pb_MeanF", fmt(s.mean)], + ["E03_Pb_Std", fmt(s.std)], + ["E03_Pb_Pf", `${fmt((s.pf ?? 0) * 100, 2)} %`], + ["E03_Pb_BetaN", fmt(s.beta_normal)], + ["E03_Pb_BetaLn", fmt(s.beta_lognormal)], + [ + "E03_Pb_PReq", + s.p_below_required === null || s.p_below_required === undefined + ? "-" + : `${fmt(s.p_below_required * 100, 2)} %`, + ], + ["E03_Pb_Valid", `${s.n_valid} / ${s.n}`], + ["E03_Pb_Time", `${fmt(rec.result.elapsed_s.total, 1)} s`], + ]; + const dl = el("dl", { className: "e03-kpi", attrs: { "data-role": "prob-summary" } }); + for (const [k, v] of rows) + dl.append(el("dt", { text: t(k) }), el("dd", { text: v, attrs: { "data-k": k } })); + const r = rec.result; + const note = `${t(`E03_Pb_Mode_${r.mode}` as LocaleKey)} · ${methodName(r.method)} · ${r.sampling.toUpperCase()} ${r.n} · seed ${r.seed} · ${rec.computed_at}`; + return el("div", { children: [dl, el("p", { className: "e03-muted", text: note })] }); + } + + function sources(setup: ProbSetup, changed: number): HTMLElement { + const d = el("details", { className: "e03-prob-src" }); + const tag = changed + ? t("E03_Pb_UserInput").replace("{n}", String(changed)) + : t("E03_Pb_Unconfirmed"); + d.append(el("summary", { text: tag })); + for (const [k, v] of Object.entries(setup.defaults.sources)) + d.append(el("p", { className: "e03-muted", text: `${k} — ${v}` })); + return d; + } + + function render(v: ProbView): HTMLElement { + const host = el("div", { className: "e03-card e03-prob", attrs: { "data-role": "prob" } }); + const head = (key: LocaleKey): HTMLElement => + el("h3", { className: "e03-card__title", text: t(key) }); + host.append(head("E03_Tab_Prob")); + if (!v.row.representative?.on) { + host.append(el("p", { className: "e03-empty", text: t("E03_Pb_RepOnly") })); + return host; + } + const side = v.side ?? v.row.sides[0]?.side; + if (!side) return host; + const slot = slotOf(v, side); + const got = setups.get(slot); + if (got === undefined) queueMicrotask(() => void load(slot, v.row.key, side, v.condition)); + if (typeof got !== "object") { + const text = typeof got === "string" && got !== "loading" ? got : t("E03_Loading"); + host.append(el("p", { className: "e03-empty", text })); + return host; + } + cache = fillCache(got, cache); + const sideText = `${t(side === "left" ? "E03_Left" : "E03_Right")} · ${t(got.role === "cut" ? "E03_Cut" : "E03_Fill")}`; + host.append( + el("p", { + className: "e03-muted", + text: `${sideText} · ${t(`E03_Cond_${v.condition}` as LocaleKey)} · ${t("E03_Pb_MeanNote")}`, + }), + ); + host.append( + inputTable(got), + runFields(got, slot, v.editable), + sources(got, changedCount(got, cache)), + ); + if (busy) + host.append( + el("p", { + className: "e03-muted", + text: `${busy.percent}% · ${busy.message}`, + attrs: { "data-role": "prob-progress" }, + }), + ); + const rec = records.get(slot); + if (!rec) return host; + const res = rec.result; + host.append( + head("E03_Pb_Result"), + summary(rec), + head("E03_Pb_Hist"), + histogramChart(res, rec.required, rec.fs_deterministic), + head("E03_Pb_Cdf"), + cdfChart(res, rec.required), + head("E03_Pb_Sens"), + sensitivityChart(res), + head("E03_Pb_Tornado"), + tornadoChart(res), + ); + return host; + } + + return { render, isBusy: (): boolean => !!busy }; +} diff --git a/E03_StabSection/E03_StabSection_UI_ProbChart.ts b/E03_StabSection/E03_StabSection_UI_ProbChart.ts new file mode 100644 index 000000000..08bf0de62 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_ProbChart.ts @@ -0,0 +1,225 @@ +/* ============================================================================= + * E03_StabSection_UI_ProbChart.ts + * E03 「확률 · 민감도」 그래프 SVG(PLAN 537-1) — 안전율 히스토그램(F < 1 빨강 · F = 1 · 기준 선) · + * 누적분포 · 민감도(가로 = 범위 % · 변수마다 선 · Slide2 Sensitivity Plot) · 토네이도(끝값 F 폭 순). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t } from "@ui/ui_template_locale"; +import { axisTicks, type ProbCurve, type ProbResult } from "./logic/E03_StabSection_Prob"; +import { pointsText, type Point } from "./logic/E03_StabSection_Logic"; +import { svgEl } from "./E03_StabSection_UI_Section"; + +const W = 380; +const H = 200; +const PAD = { l: 44, r: 12, t: 22, b: 28 }; +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); + +interface Frame { + svg: SVGSVGElement; + sx: (x: number) => number; + sy: (y: number) => number; +} + +/** 축 틀 — 가로 · 세로 눈금 · 축 이름 */ +function frame( + role: string, + xr: [number, number], + yr: [number, number], + xLabel: string, + yLabel: string, + pad = PAD, + yAxis = true, +): Frame { + const xt = axisTicks(xr[0], xr[1], 5); + const yt = axisTicks(yr[0], yr[1], 4); + const [x0, x1] = [xt[0], xt[xt.length - 1]]; + const [y0, y1] = [yt[0], yt[yt.length - 1]]; + const sx = (x: number): number => + pad.l + ((x - x0) / Math.max(x1 - x0, 1e-9)) * (W - pad.l - pad.r); + const sy = (y: number): number => + H - pad.b - ((y - y0) / Math.max(y1 - y0, 1e-9)) * (H - pad.t - pad.b); + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": role }) as SVGSVGElement; + const axis = svgEl("g", { class: "e03-axis" }); + for (const v of yAxis ? yt : []) { + axis.append(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: sy(v), y2: sy(v) })); + axis.append(svgEl("text", { x: pad.l - 4, y: sy(v) + 4, "text-anchor": "end" }, String(v))); + } + for (const v of xt) + axis.append(svgEl("text", { x: sx(v), y: H - pad.b + 14, "text-anchor": "middle" }, String(v))); + axis.append(svgEl("text", { x: W - pad.r, y: H - 2, "text-anchor": "end" }, xLabel)); + axis.append(svgEl("text", { x: 2, y: 10 }, yLabel)); + svg.append(axis); + return { svg, sx, sy }; +} + +/** 세로 표지선 · 글 — row = 글 줄(가까운 표지끼리 안 겹치게 아래로) */ +function mark(f: Frame, x: number, cls: string, label: string, row = 0): void { + f.svg.append( + svgEl("line", { + x1: f.sx(x), + x2: f.sx(x), + y1: PAD.t, + y2: H - PAD.b, + class: `e03-prob-mark ${cls}`, + }), + svgEl( + "text", + { x: f.sx(x) + 3, y: PAD.t + 10 + 12 * row, class: `e03-prob-mark__text ${cls}` }, + label, + ), + ); +} + +function box(role: string, svg: SVGSVGElement, legend?: HTMLElement): HTMLElement { + const host = el("div", { className: "e03-graph", attrs: { "data-role": `${role}-box` } }); + host.append(svg as unknown as HTMLElement); + if (legend) host.append(legend); + return host; +} + +/** 안전율 히스토그램 — 칸 개수 · F < 1 칸 빨강 · F = 1 · 기준 · 결정론 선 */ +export function histogramChart( + res: ProbResult, + required: number | null, + fsDet: number | null, +): HTMLElement { + const h = res.stats.histogram; + if (!h) return el("div"); + const { edges, counts } = h; + const xr: [number, number] = [ + Math.min(edges[0], 1), + Math.max(edges[edges.length - 1], required ?? 0), + ]; + const f = frame("prob-hist", xr, [0, Math.max(...counts, 1)], "Fs", t("E03_Pb_Count")); + counts.forEach((n, i) => { + const [a, b] = [edges[i], edges[i + 1]]; + const bar = svgEl("rect", { + x: f.sx(a), + y: f.sy(n), + width: Math.max(f.sx(b) - f.sx(a) - 0.5, 0.5), + height: f.sy(0) - f.sy(n), + class: b <= 1 ? "e03-prob-bar e03-prob-bar--fail" : "e03-prob-bar", + "data-count": n, + }); + bar.append(svgEl("title", {}, `${fmt(a, 3)} ~ ${fmt(b, 3)} · ${n}`)); + f.svg.append(bar); + }); + mark(f, 1, "e03-prob-mark--one", "F = 1"); + if (required) mark(f, required, "e03-prob-mark--req", `${t("E03_Required")} ${fmt(required)}`, 1); + if (fsDet) mark(f, fsDet, "e03-prob-mark--det", t("E03_Pb_Det"), 2); + return box("prob-hist", f.svg); +} + +/** 누적분포 — 가로 F · 세로 P(F ≤ x) · F = 1 에서 Pf */ +export function cdfChart(res: ProbResult, required: number | null): HTMLElement { + const c = res.stats.cdf; + if (!c) return el("div"); + const xr: [number, number] = [Math.min(c.F[0], 1), Math.max(c.F[c.F.length - 1], required ?? 0)]; + const f = frame("prob-cdf", xr, [0, 1], "Fs", "P(F ≤ x)"); + const pts: Point[] = c.F.map((x, i) => [f.sx(x), f.sy(c.p[i])]); + f.svg.append( + svgEl("polyline", { points: pointsText(pts), class: "e03-prob-line e03-prob-line--0" }), + ); + mark(f, 1, "e03-prob-mark--one", `Pf ${fmt((res.stats.pf ?? 0) * 100, 1)} %`); + if (required) mark(f, required, "e03-prob-mark--req", `${t("E03_Required")} ${fmt(required)}`, 1); + return box("prob-cdf", f.svg); +} + +function legendOf(curves: ProbCurve[]): HTMLElement { + const legend = el("div", { className: "e03-legend" }); + curves.forEach((c, i) => { + const item = el("span", { className: "e03-legend__item" }); + const sw = svgEl("svg", { width: 18, height: 8 }); + sw.append( + svgEl("line", { + x1: 0, + x2: 18, + y1: 4, + y2: 4, + class: `e03-prob-line e03-prob-line--${i % 8}`, + }), + ); + item.append(sw as unknown as HTMLElement, c.name); + legend.append(item); + }); + return legend; +} + +/** 민감도 — 가로 = 범위 %(0 = 아래 끝 · 100 = 위 끝) · 나머지 변수 평균 · 임계면 고정 */ +export function sensitivityChart(res: ProbResult): HTMLElement { + const curves = res.sensitivity.curves; + const all = curves.flatMap((c) => c.F).filter((v): v is number => v !== null); + if (!all.length) return el("div"); + const f = frame( + "prob-sens", + [0, 100], + [Math.min(...all, 1), Math.max(...all)], + t("E03_Pb_Range"), + "Fs", + ); + curves.forEach((c, i) => { + const n = c.F.length; + const pts: Point[] = []; + c.F.forEach((v, k) => { + if (v !== null) pts.push([f.sx((100 * k) / Math.max(n - 1, 1)), f.sy(v)]); + }); + const line = svgEl("polyline", { + points: pointsText(pts), + class: `e03-prob-line e03-prob-line--${i % 8}`, + "data-name": c.name, + }); + line.append(svgEl("title", {}, `${c.name} · ${fmt(c.x[0])} ~ ${fmt(c.x[n - 1])}`)); + f.svg.append(line); + }); + return box("prob-sens", f.svg, legendOf(curves)); +} + +/** 토네이도 — 변수마다 아래 끝 · 위 끝 F 막대(평균 F 기준) · 폭 큰 순 */ +export function tornadoChart(res: ProbResult): HTMLElement { + const rows = res.sensitivity.tornado; + const F0 = res.sensitivity.F_mean; + if (!rows.length || F0 === null) return el("div"); + const vals = rows.flatMap((r) => [r.F_lo, r.F_hi]).filter((v): v is number => v !== null); + const pad = { ...PAD, l: 96 }; + const f = frame( + "prob-tornado", + [Math.min(...vals), Math.max(...vals)], + [0, 1], + "Fs", + "", + pad, + false, + ); + const step = (H - PAD.t - PAD.b) / rows.length; + rows.forEach((r, i) => { + const y = PAD.t + i * step + step * 0.15; + const hh = step * 0.7; + for (const [v, cls] of [ + [r.F_lo, "e03-prob-tbar--lo"], + [r.F_hi, "e03-prob-tbar--hi"], + ] as const) { + if (v === null) continue; + const [a, b] = [Math.min(f.sx(v), f.sx(F0)), Math.max(f.sx(v), f.sx(F0))]; + const bar = svgEl("rect", { + x: a, + y, + width: Math.max(b - a, 0.5), + height: hh, + class: `e03-prob-tbar ${cls}`, + }); + bar.append(svgEl("title", {}, `${r.name} · ${fmt(v, 3)}`)); + f.svg.append(bar); + } + f.svg.append( + svgEl( + "text", + { x: pad.l - 4, y: y + hh / 2 + 4, "text-anchor": "end", class: "e03-prob-tlabel" }, + r.name, + ), + ); + }); + mark(f, F0, "e03-prob-mark--det", fmt(F0, 3)); + return box("prob-tornado", f.svg); +} diff --git a/E03_StabSection/E03_StabSection_UI_Reinforce.ts b/E03_StabSection/E03_StabSection_UI_Reinforce.ts new file mode 100644 index 000000000..58216e0d5 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Reinforce.ts @@ -0,0 +1,315 @@ +/* ============================================================================= + * E03_StabSection_UI_Reinforce.ts + * E03 「보강 검토」 탭(PLAN 536-3) — 고른 측점 비탈 가정(1:n 완화 · 소단 폭 · 높이 · 최소 완화 찾기) + * → 서버 재해석(POST …/stab/whatif/{측점}) · 전후 안전율 · 판정 표 · 후 단면 그림 · + * 벽 있는 측점 = 벽 외적 안정 표(POST …/stab/wallcheck/{측점} · 활동 · 전도 · 지지력). + * 가정은 형상만 바꾼 따로 저장본(원본 결과 그대로) · 계산은 서버. + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSectionCanvas } from "./E03_StabSection_UI_Section"; +import { verdictChip } from "./E03_StabSection_UI_Result"; +import type { Condition, SectionView, SideCase, StationRow } from "./logic/E03_StabSection_Logic"; + +export interface WhatIfRecord { + type: "slope"; + station_key: string; + assume: { + side: string; + role: "cut" | "fill"; + ratio: number; + ratio_before: number; + bench_w: number; + bench_h: number; + }; + search: { + found: number | null; + gentlest: number; + tried: [number, boolean][]; + condition: Condition | null; + } | null; + before: Partial>; + after: Partial>; + elapsed_s: number; + view: SectionView; +} + +type Check = "sliding" | "overturning" | "bearing"; +interface WallCase { + case: "normal" | "seismic"; + fs: Record; + required: Record; + ok: Record; + coefficients: { ka: number; kae: number | null }; + base: { e: number; q_max: number; q_ult: number }; + geometry: { H: number; B: number }; +} +export interface WallRecord { + type: "wall"; + params: { method: string; water_ratio: number; kh: number }; + walls: { name: string | null; side: string; role: string; cases: WallCase[] }[]; +} + +export interface SlopeAsk { + side: string; + ratio: number | null; + bench_w: number; + bench_h: number; + search: boolean; + /** 최소 완화 찾기 목표 조건(그 조건 기준안전율) */ + condition: Condition | null; +} +export interface WallAsk { + method: string; + water_ratio: number; +} + +export interface ReinforceOptions { + row: StationRow; + condition: Condition; + side: string | null; + editable: boolean; + busy: boolean; + slope: WhatIfRecord | undefined; + wall: WallRecord | undefined; + runSlope: (ask: SlopeAsk) => void; + runWall: (ask: WallAsk) => void; +} + +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); +const CONDS: Condition[] = ["dry", "wet", "seismic"]; +const CHECKS: Check[] = ["sliding", "overturning", "bearing"]; + +function num(input: HTMLInputElement): number | null { + const v = Number(input.value); + return input.value.trim() === "" || !Number.isFinite(v) ? null : v; +} + +/** 전후 안전율 · 판정 표 — 비탈 가정 · 네일 · 앵커 가정 같이 씀 */ +export function slopeTable(rec: Pick): HTMLElement { + const table = el("table", { className: "e03-table", attrs: { "data-role": "whatif-table" } }); + const head = ["E03_Condition", "E03_Rf_Before", "E03_Rf_After", "E03_Required"] as LocaleKey[]; + table.append( + el("thead", { + children: [ + el("tr", { + children: [ + ...head.map((k, i) => el("th", { className: i ? "is-num" : "", text: t(k) })), + el("th", { text: t("E03_Rf_VerdictBefore") }), + el("th", { text: t("E03_Rf_VerdictAfter") }), + ], + }), + ], + }), + ); + const body = el("tbody"); + for (const cond of CONDS) { + const a = rec.after[cond]; + if (!a) continue; + const b = rec.before[cond]; + const tr = el("tr", { attrs: { "data-cond": cond } }); + tr.append( + el("td", { text: t(`E03_Cond_${cond}` as LocaleKey) }), + el("td", { className: "is-num", text: fmt(b?.fs, 3) }), + el("td", { className: "is-num", text: fmt(a.fs, 3), attrs: { "data-role": "fs-after" } }), + el("td", { className: "is-num", text: fmt(a.required) }), + el("td", { children: [verdictChip(b?.ok ?? null)] }), + el("td", { children: [verdictChip(a.ok)] }), + ); + body.append(tr); + } + table.append(body); + return table; +} + +function assumeText(rec: WhatIfRecord): string { + const a = rec.assume; + let text = `1:${a.ratio_before} → 1:${a.ratio}`; + if (a.bench_w && a.bench_h) text += ` · ${t("E03_Rf_Bench")} ${a.bench_w} m / ${a.bench_h} m`; + if (rec.search) + text += rec.search.found + ? ` · ${t("E03_Rf_Found")} (${rec.search.tried.length}${t("E03_Iter")})` + : ` · ${t("E03_Rf_NotFound").replace("{n}", String(rec.search.gentlest))}`; + return `${text} · ${rec.elapsed_s} s`; +} + +function wallTable(rec: WallRecord): HTMLElement { + const box = el("div", { attrs: { "data-role": "wall-table" } }); + for (const wall of rec.walls) { + for (const c of wall.cases) { + box.append( + el("p", { + className: "e03-muted", + text: + `${wall.name ?? t("E03_Rf_Wall")} · ${t(`E03_Rf_Case_${c.case}` as LocaleKey)} · ` + + `H ${fmt(c.geometry.H)} m · B ${fmt(c.geometry.B)} m · Ka ${fmt(c.coefficients.ka, 3)}` + + (c.coefficients.kae ? ` · KAE ${fmt(c.coefficients.kae, 3)}` : "") + + ` · e ${fmt(c.base.e)} m · q_max ${fmt(c.base.q_max, 1)} kPa`, + }), + ); + const table = el("table", { className: "e03-table" }); + table.append( + el("thead", { + children: [ + el("tr", { + children: [ + el("th", { text: t("E03_Rf_Check") }), + el("th", { className: "is-num", text: t("E03_Rf_Fs") }), + el("th", { className: "is-num", text: t("E03_Required") }), + el("th", { text: t("E03_Verdict") }), + ], + }), + ], + }), + ); + const body = el("tbody"); + for (const k of CHECKS) + body.append( + el("tr", { + attrs: { "data-check": k, "data-case": c.case }, + children: [ + el("td", { text: t(`E03_Rf_${k}` as LocaleKey) }), + el("td", { className: "is-num", text: fmt(c.fs[k]) }), + el("td", { className: "is-num", text: fmt(c.required[k], 1) }), + el("td", { children: [verdictChip(c.ok[k])] }), + ], + }), + ); + table.append(body); + box.append(table); + } + } + return box; +} + +export function buildReinforce(o: ReinforceOptions): HTMLElement { + const host = el("div", { className: "e03-card", attrs: { "data-role": "reinforce" } }); + const sides = o.row.sides; + const sideField = createSelectField({ + label: t("E03_Side"), + options: sides.map((s) => ({ + value: s.side, + text: `${t(s.side === "left" ? "E03_Left" : "E03_Right")} · ${t(s.role === "cut" ? "E03_Cut" : "E03_Fill")}`, + })), + value: o.slope?.assume.side ?? o.side ?? undefined, + compact: true, + }); + const ratio = createInputField({ + label: t("E03_Rf_Ratio"), + type: "number", + min: 0.1, + max: 4, + compact: true, + value: o.slope ? String(o.slope.assume.ratio) : "", + placeholder: t("E03_Rf_RatioNow"), + }); + const benchW = createInputField({ + label: t("E03_Rf_BenchW"), + type: "number", + min: 0, + compact: true, + value: o.slope?.assume.bench_w ? String(o.slope.assume.bench_w) : "", + }); + const benchH = createInputField({ + label: t("E03_Rf_BenchH"), + type: "number", + min: 0, + compact: true, + value: o.slope?.assume.bench_h ? String(o.slope.assume.bench_h) : "", + }); + for (const f of [ratio, benchW, benchH]) f.input.step = "0.05"; + const ask = (search: boolean): SlopeAsk => ({ + side: sideField.select.value, + ratio: search ? null : num(ratio.input), + bench_w: num(benchW.input) ?? 0, + bench_h: num(benchH.input) ?? 0, + search, + condition: search ? o.condition : null, + }); + const run = createButton({ + label: o.busy ? t("E03_Rf_Busy") : t("E03_Rf_Run"), + variant: "filled", + onClick: () => o.runSlope(ask(false)), + }); + const find = createButton({ + label: t("E03_Rf_Search"), + variant: "ghost", + onClick: () => o.runSlope(ask(true)), + }); + run.disabled = find.disabled = o.busy || !o.editable; + run.dataset.role = "whatif-run"; + find.dataset.role = "whatif-search"; + const fields = el("div", { className: "e03-panel__row" }); + fields.append(sideField.root, ratio.root, benchW.root, benchH.root); + const buttons = el("div", { className: "e03-panel__row" }); + buttons.append(find, run); + host.append( + el("h3", { className: "e03-card__title", text: t("E03_Rf_Title") }), + el("p", { className: "e03-muted", text: t("E03_Rf_Hint") }), + el("p", { + className: "e03-muted", + text: t("E03_Rf_Goal").replace("{c}", t(`E03_Cond_${o.condition}` as LocaleKey)), + }), + fields, + buttons, + ); + if (o.slope) { + host.append( + el("p", { + className: "e03-muted", + text: assumeText(o.slope), + attrs: { "data-role": "assume" }, + }), + slopeTable(o.slope), + el("h4", { className: "e03-card__title", text: t("E03_Rf_AfterSection") }), + ); + const canvas = createSectionCanvas(() => undefined); + canvas.root.style.height = "260px"; + canvas.root.style.flex = "none"; + canvas.root.dataset.role = "whatif-section"; + host.append(canvas.root); + const analyses = o.slope.view.analyses.filter((a) => a.condition === o.condition); + const analysis = analyses.find((a) => a.side === o.slope!.assume.side) ?? analyses[0] ?? null; + const draw = { + view: o.slope.view, + analysis, + toggles: { map: false, centers: false, candidates: false }, + picked: null, + }; + canvas.render(draw); // 붙기 전 = 기본 폭 · 붙으면 ResizeObserver 가 다시 그림 + } + if (o.row.walls) { + const method = createSelectField({ + label: t("E03_Rf_Method"), + options: [ + { value: "coulomb", text: "Coulomb" }, + { value: "rankine", text: "Rankine" }, + ], + value: o.wall?.params.method, + compact: true, + }); + const water = createInputField({ + label: t("E03_Rf_Water"), + type: "number", + min: 0, + max: 1, + compact: true, + value: String(o.wall?.params.water_ratio ?? 0), + }); + water.input.step = "0.1"; + const check = createButton({ + label: t("E03_Rf_WallRun"), + variant: "ghost", + onClick: () => o.runWall({ method: method.select.value, water_ratio: num(water.input) ?? 0 }), + }); + check.disabled = o.busy || !o.editable; + check.dataset.role = "wall-run"; + const row = el("div", { className: "e03-panel__row" }); + row.append(method.root, water.root, check); + host.append(el("h3", { className: "e03-card__title", text: t("E03_Rf_WallTitle") }), row); + if (o.wall) host.append(wallTable(o.wall)); + } + return host; +} diff --git a/E03_StabSection/E03_StabSection_UI_Result.ts b/E03_StabSection/E03_StabSection_UI_Result.ts new file mode 100644 index 000000000..70e35e2a4 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Result.ts @@ -0,0 +1,299 @@ +/* ============================================================================= + * E03_StabSection_UI_Result.ts + * E03 우측 결과(PLAN 532-3) — 안전율 · 기준 · 판정 · 방법 · 수렴 · 중심/반경 · 방법별 최소 안전율 · + * 절편표(누르면 고름) · 절편 자유물체도(고른 절편 힘 화살) · 절편력 그래프(바닥 유효 수직응력 · + * 간극수압 · 동원 전단응력 — kPa 한 축). 계산은 logic. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { + forceArrows, + niceTicks, + pointsText, + sliceShape, + sliceStress, + type Analysis, + type Point, + type SectionView, +} from "./logic/E03_StabSection_Logic"; +import { WALL_ID, materialClass, svgEl } from "./E03_StabSection_UI_Section"; + +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); + +const METHODS = ["ordinary", "bishop", "janbu", "spencer"] as const; + +export function verdictChip(ok: boolean | null): HTMLElement { + const v = ok === null ? "none" : ok ? "ok" : "ng"; + return el("span", { + className: `e03-verdict e03-verdict--${v}`, + text: t(`E03_Verdict_${v}` as LocaleKey), + }); +} + +function kpi(analysis: Analysis, walls: number): HTMLElement { + const c = analysis.critical; + const dl = el("dl", { className: "e03-kpi" }); + const row = (label: string, value: HTMLElement | string, big = false): void => { + const dd = el("dd", { className: big ? "is-big" : "" }); + dd.append(value); + dl.append(el("dt", { text: label }), dd); + }; + row(t("E03_Fs"), fmt(c?.fs, 3), true); + row(t("E03_Verdict"), verdictChip(analysis.ok)); + row(t("E03_Required"), fmt(analysis.required)); + row( + t("E03_Method"), + `${t("E03_Method_bishop")} · ${c?.kind === "circle" ? t("E03_Circle") : t("E03_NonCircle")}`, + ); + row( + t("E03_Converged"), + c + ? `${c.converged ? t("E03_Yes") : t("E03_No")} · ${c.iterations ?? "-"}${t("E03_Iter")}` + : "-", + ); + row(t("E03_Height"), `${fmt(analysis.height_m)} m`); + if (walls) + row(t("E03_Walls"), el("span", { text: String(walls), attrs: { "data-role": "walls" } })); + if (c?.center) { + row(t("E03_Center"), `${fmt(c.center[0])}, ${fmt(c.center[1])}`); + row(t("E03_Radius"), `${fmt(c.radius)} m`); + } + return dl; +} + +function methodTable(analysis: Analysis): HTMLElement { + const table = el("table", { className: "e03-table", attrs: { "data-role": "methods" } }); + const head = el("tr"); + const body = el("tr"); + for (const m of METHODS) { + head.append(el("th", { className: "is-num", text: t(`E03_Method_${m}` as LocaleKey) })); + const v = analysis.min_by_method[m]; + const unsolved = (v === null || v === undefined) && analysis.no_solution?.includes(m); + body.append( + el("td", { className: "is-num", text: unsolved ? t("E03_NoSolution") : fmt(v, 3) }), + ); + } + table.append(el("thead", { children: [head] }), el("tbody", { children: [body] })); + return table; +} + +function sliceTable( + analysis: Analysis, + picked: number | null, + onPick: (i: number | null) => void, +): HTMLElement { + const wrap = el("div", { className: "e03-slices" }); + const table = el("table", { className: "e03-table", attrs: { "data-role": "slice-table" } }); + const cols: [LocaleKey, (s: Analysis["slices"][number]) => string][] = [ + ["E03_Col_No", (s) => String(s.i)], + ["E03_Col_Width", (s) => fmt(s.width)], + ["E03_Col_Height", (s) => fmt(s.height)], + ["E03_Col_Alpha", (s) => fmt(s.alpha_deg, 1)], + ["E03_Col_W", (s) => fmt(s.weight + s.surcharge, 1)], + ["E03_Col_U", (s) => fmt(s.pore_pressure, 1)], + ["E03_Col_N", (s) => fmt(s.N_eff, 1)], + ["E03_Col_S", (s) => fmt(s.S, 1)], + ["E03_Col_C", (s) => fmt(s.c, 0)], + ["E03_Col_Phi", (s) => fmt(s.phi_deg, 0)], + ]; + table.append( + el("thead", { + children: [ + el("tr", { children: cols.map(([k]) => el("th", { className: "is-num", text: t(k) })) }), + ], + }), + ); + const body = el("tbody"); + analysis.slices.forEach((s, i) => { + const tr = el("tr", { + className: picked === i ? "is-picked" : "", + attrs: { "data-i": String(i) }, + }); + for (const [, f] of cols) tr.append(el("td", { className: "is-num", text: f(s) })); + tr.addEventListener("click", () => onPick(picked === i ? null : i)); + body.append(tr); + }); + table.append(body); + wrap.append(table); + if (picked !== null) + queueMicrotask(() => body.children[picked]?.scrollIntoView({ block: "nearest" })); + return wrap; +} + +/** 자유물체도 — 고른 절편을 판에 맞춰 키우고 힘 화살(길이 = 힘 비례) · 값 글 */ +function freeBody(view: SectionView, analysis: Analysis, picked: number | null): HTMLElement { + const box = el("div", { className: "e03-fbd" }); + const slice = picked !== null ? analysis.slices[picked] : null; + if (!slice || !analysis.critical) { + box.append(el("p", { className: "e03-muted", text: t("E03_PickSlice") })); + return box; + } + const W = 380; + const H = 260; + const shape = sliceShape(slice, view.surface, analysis.critical.points); + const xs = shape.map((p) => p[0]); + const zs = shape.map((p) => p[1]); + const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...zs) - Math.min(...zs), 0.01); + const k = 90 / span; // 절편 = 약 90 px + const cx = (Math.min(...xs) + Math.max(...xs)) / 2; + const cz = (Math.min(...zs) + Math.max(...zs)) / 2; + // 화면 = 횡단과 같은 방향(좌측 +offset 이 왼쪽) + const scr = (p: Point): Point => [W / 2 - k * (p[0] - cx), H / 2 - k * (p[1] - cz)]; + const forces = [slice.weight, slice.surcharge, slice.N_eff, slice.U, slice.S].concat( + [slice.E_left, slice.E_right, slice.X_left, slice.X_right].map(Math.abs), + ); + const maxF = Math.max(...forces, 1e-6); + const lengthPerKn = 80 / k / maxF; // 가장 큰 힘 = 80 px + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "fbd" }); + const defs = svgEl("defs"); + const marker = svgEl("marker", { + id: "e03-head", + viewBox: "0 0 10 10", + refX: 9, + refY: 5, + markerWidth: 7, + markerHeight: 7, + orient: "auto-start-reverse", + }); + marker.append(svgEl("path", { d: "M0,0 L10,5 L0,10 z", fill: "context-stroke" })); + defs.append(marker); + svg.append(defs); + svg.append( + svgEl("polygon", { + points: shape.map((p) => scr(p).join(",")).join(" "), + class: `e03-region ${materialClass(view, slice.material ?? "")}`, + }), + ); + for (const a of forceArrows(slice, shape, analysis.direction, lengthPerKn)) { + const [p0, p1] = [scr(a.from), scr(a.to)]; + svg.append( + svgEl("line", { + x1: p0[0], + y1: p0[1], + x2: p1[0], + y2: p1[1], + class: `e03-arrow e03-arrow--${a.name}`, + "marker-end": "url(#e03-head)", + "data-force": a.name, + "data-value": String(a.value), + }), + ); + // 글 = 화살 바깥 끝(들어오는 힘 = 꼬리 · 나가는 힘 = 머리)에서 화살 방향으로 조금 더 + const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) || 1; + const d: Point = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len]; + const ending = a.name !== "W"; + const out: Point = ending ? [-d[0], -d[1]] : d; + const at: Point = ending ? p0 : p1; + const anchor = out[0] > 0.3 ? "start" : out[0] < -0.3 ? "end" : "middle"; + const label = { x: at[0] + out[0] * 6, y: at[1] + out[1] * 6 + (out[1] > 0.3 ? 10 : 0) }; + svg.append( + svgEl( + "text", + { ...label, "text-anchor": anchor, class: "e03-label" }, + `${a.name} ${fmt(a.value, 1)}`, + ), + ); + } + box.append(svg); + box.append( + el("p", { + className: "e03-muted", + text: `#${slice.i} · α ${fmt(slice.alpha_deg, 1)}° · b ${fmt(slice.width)} m · ${t("E03_FbdUnit")}`, + }), + ); + return box; +} + +/** 절편력 그래프 — x = 절편 가운데(횡단과 같은 방향) · y = kPa(한 축) · 고른 절편 세로선 */ +function stressGraph(analysis: Analysis, picked: number | null): HTMLElement { + const box = el("div", { className: "e03-graph" }); + const rows = sliceStress(analysis.slices); + if (!rows.length) return box; + const W = 380; + const H = 200; + const pad = { l: 44, r: 10, t: 10, b: 26 }; + const xs = rows.map((r) => r.x); + const values = rows.flatMap((r) => [r.sigma, r.u, r.tau]); + const ticks = niceTicks(Math.min(...values), Math.max(...values), 4); + const [y0, y1] = [ticks[0], ticks[ticks.length - 1]]; + const [x0, x1] = [Math.min(...xs), Math.max(...xs)]; + const sx = (x: number): number => + pad.l + ((x1 - x) / Math.max(x1 - x0, 1e-6)) * (W - pad.l - pad.r); + const sy = (y: number): number => + H - pad.b - ((y - y0) / Math.max(y1 - y0, 1e-6)) * (H - pad.t - pad.b); + const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "stress-graph" }); + const axis = svgEl("g", { class: "e03-axis" }); + for (const v of ticks) { + axis.append(svgEl("line", { x1: pad.l, x2: W - pad.r, y1: sy(v), y2: sy(v) })); + axis.append(svgEl("text", { x: pad.l - 4, y: sy(v) + 4, "text-anchor": "end" }, String(v))); + } + axis.append(svgEl("text", { x: pad.l, y: H - 6 }, `${fmt(x1, 1)} m`)); + axis.append(svgEl("text", { x: W - pad.r, y: H - 6, "text-anchor": "end" }, `${fmt(x0, 1)} m`)); + axis.append(svgEl("text", { x: W / 2, y: H - 6, "text-anchor": "middle" }, t("E03_Offset"))); + svg.append(axis); + if (picked !== null && rows[picked]) { + const x = sx(rows[picked].x); + svg.append(svgEl("line", { x1: x, x2: x, y1: pad.t, y2: H - pad.b, class: "e03-radius" })); + } + for (const key of ["sigma", "u", "tau"] as const) { + const pts: Point[] = rows.map((r) => [sx(r.x), sy(r[key])]); + svg.append(svgEl("polyline", { points: pointsText(pts), class: `e03-line e03-line--${key}` })); + } + // 가는 기둥마다 툴팁(절편 번호 · 세 값) + rows.forEach((r, i) => { + const w = (W - pad.l - pad.r) / rows.length; + const hit = svgEl("rect", { + x: sx(r.x) - w / 2, + y: pad.t, + width: w, + height: H - pad.t - pad.b, + fill: "transparent", + }); + hit.append( + svgEl( + "title", + {}, + `#${i + 1} · σ' ${fmt(r.sigma, 1)} · u ${fmt(r.u, 1)} · τ ${fmt(r.tau, 1)} kPa`, + ), + ); + svg.append(hit); + }); + box.append(svg); + const legend = el("div", { className: "e03-legend" }); + for (const [key, label] of [ + ["sigma", "E03_Graph_Sigma"], + ["u", "E03_Graph_U"], + ["tau", "E03_Graph_Tau"], + ] as const) { + const item = el("span", { className: "e03-legend__item" }); + const sw = svgEl("svg", { width: 18, height: 8 }); + sw.append(svgEl("line", { x1: 0, x2: 18, y1: 4, y2: 4, class: `e03-line e03-line--${key}` })); + item.append(sw as unknown as HTMLElement, t(label as LocaleKey)); + legend.append(item); + } + box.append(legend); + return box; +} + +export function buildResult( + view: SectionView, + analysis: Analysis | null, + picked: number | null, + onPick: (i: number | null) => void, +): HTMLElement { + const root = el("div", { className: "e03-result e03-card" }); + if (!analysis || !analysis.critical) { + root.append(el("p", { className: "e03-empty", text: t("E03_NoSurface") })); + return root; + } + const head = (key: LocaleKey): HTMLElement => + el("h3", { className: "e03-card__title", text: t(key) }); + const walls = view.regions.filter((r) => r.material_id === WALL_ID).length; + root.append(head("E03_Result"), kpi(analysis, walls), methodTable(analysis)); + root.append(head("E03_Slices"), sliceTable(analysis, picked, onPick)); + root.append(head("E03_Fbd"), freeBody(view, analysis, picked)); + root.append(head("E03_Graph"), stressGraph(analysis, picked)); + return root; +} diff --git a/E03_StabSection/E03_StabSection_UI_Section.ts b/E03_StabSection/E03_StabSection_UI_Section.ts new file mode 100644 index 000000000..ff43d86e6 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Section.ts @@ -0,0 +1,424 @@ +/* ============================================================================= + * E03_StabSection_UI_Section.ts + * E03 가운데 횡단(PLAN 532-3) — 지층 색 · 벽(회색 빗금 · 532-5) · 지표 · 지하수선 · 노면 하중 · 등안전율 색 지도(원호 중심 격자 + * 최소 안전율) · 원호 중심 점 · 다른 후보(옅게) · 임계 활동면(굵게 · 중심 · 반경) · 절편(누르면 고름) · + * 보강 검토 네일 · 앵커 선(536-3 · 있을 때만). + * 도형 좌표 = 결과 좌표 그대로 · 묶음 transform(`viewMatrix`) 하나 — 계산은 logic. + * 줌 · 팬 = 공용 `attachSvgZoomPan`(휠 = 줌 · 가운데 끌기 = 팬 · 두 번 누름 = 원배율). + * ========================================================================== */ + +import "./E03_StabSection_UI_Walls.css"; +import "./E03_StabSection_UI_Support.css"; +import { t } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; +import { + bandIndex, + bandLegend, + fitView, + focusHome, + pointsText, + sliceShape, + toModel, + toScreen, + viewMatrix, + type Analysis, + type Point, + type SectionView, +} from "./logic/E03_StabSection_Logic"; +import type { SupportDraw } from "./logic/E03_StabSection_Support"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const PAD = 28; +const MAX_SCALE = 30; +/** 벽(옹벽 · 석축) 물성 id — 서버 `WALL_MATERIAL` */ +export const WALL_ID = "wall"; + +export function svgEl( + tag: K, + attrs: Record = {}, + text?: string, +): SVGElementTagNameMap[K] { + const node = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); + if (text !== undefined) node.textContent = text; + return node; +} + +export interface SectionToggles { + map: boolean; + centers: boolean; + candidates: boolean; +} + +export interface SectionDraw { + view: SectionView; + analysis: Analysis | null; + toggles: SectionToggles; + picked: number | null; + /** 보강 검토 네일 · 앵커(결과 좌표 · `supportDraws`) */ + supports?: SupportDraw[]; +} + +const fmt = (v: number | null | undefined, d = 3): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); + +/** 지층 색 번호 — 물성 표 차례(정해진 차례 · 순환 없이 8 넘으면 마지막 색) */ +export function materialClass(view: SectionView, id: string): string { + const i = view.materials.findIndex((m) => m.id === id); + return `e03-mat-${Math.min(Math.max(i, 0), 7)}`; +} + +/** 범례 줄 — 등안전율 띠(기준안전율 기준 실제 값) · 지층 */ +export function buildLegends(draw: SectionDraw): HTMLElement { + const box = el("div", { className: "e03-legend" }); + const required = draw.analysis?.required ?? null; + if (draw.toggles.map && draw.analysis?.grid) { + box.append(el("span", { className: "e03-muted", text: t("E03_Legend_Map") })); + for (const band of bandLegend(required)) { + const text = + band.from === null + ? `< ${band.to}` + : band.to === null + ? `≥ ${band.from}` + : `${band.from}–${band.to}`; + const item = el("span", { + className: "e03-legend__item", + attrs: { "data-band": String(band.index) }, + }); + item.append(el("span", { className: `e03-legend__swatch e03-band-${band.index}` }), text); + box.append(item); + } + } + const used = new Set(draw.view.regions.map((r) => r.material_id)); + box.append(el("span", { className: "e03-muted", text: t("E03_Legend_Layers") })); + for (const m of draw.view.materials.filter((x) => used.has(x.id))) { + const item = el("span", { className: "e03-legend__item" }); + item.append( + el("span", { + className: `e03-legend__swatch ${m.id === WALL_ID ? "e03-wall-swatch" : materialClass(draw.view, m.id)}`, + }), + m.name, + ); + box.append(item); + } + return box; +} + +export interface SectionCanvas { + root: HTMLElement; + render: (draw: SectionDraw | null) => void; +} + +export function createSectionCanvas(onPick: (index: number | null) => void): SectionCanvas { + const root = el("div", { className: "e03-section" }); + const tip = el("div", { className: "e03-tip" }); + let last: SectionDraw | null = null; + let zoom: ZoomPanState | undefined; + let zoomKey = ""; + + const paint = (): void => { + root.replaceChildren(tip); + const draw = last; + if (!draw) return; + const width = Math.max(root.clientWidth, 320); + const height = Math.max(root.clientHeight, 240); + const { view, analysis, toggles } = draw; + const fit = fitView(view, width, height, PAD); + const svg = svgEl("svg", { viewBox: `0 0 ${width} ${height}`, "data-role": "section" }); + const layer = svgEl("g"); + const world = svgEl("g", { transform: viewMatrix(fit), "data-role": "world" }); + const ns = { "vector-effect": "non-scaling-stroke" }; + const unit = 1 / fit.k; // 1 px 의 결과 좌표 길이 + + // 벽 빗금 — 결과 좌표 패턴(화면 6 px 간격) + const hatch = svgEl("pattern", { + id: "e03-wall-hatch", + patternUnits: "userSpaceOnUse", + width: 6 * unit, + height: 6 * unit, + patternTransform: "rotate(45)", + }); + hatch.append( + svgEl("rect", { width: 6 * unit, height: 6 * unit, class: "e03-wall-hatch__bg" }), + svgEl("line", { + x1: 0, + y1: 0, + x2: 0, + y2: 6 * unit, + "stroke-width": 1.5 * unit, + class: "e03-wall-hatch__line", + }), + ); + const defs = svgEl("defs"); + defs.append(hatch); + world.append(defs); + for (const region of view.regions) { + const wall = region.material_id === WALL_ID; + const ring = svgEl("polygon", { + points: pointsText(region.polygon), + class: wall ? "e03-wall" : `e03-region ${materialClass(view, region.material_id)}`, + ...(wall ? { fill: "url(#e03-wall-hatch)", "data-role": "wall" } : {}), + ...ns, + }); + world.append(ring); + } + // 등안전율 색 지도 — 격자 중심마다 칸(이웃 중심 사이 절반) + const grid = analysis?.grid; + if (grid && toggles.map) { + const half = (arr: number[], i: number): [number, number] => { + const lo = + i > 0 ? (arr[i] - arr[i - 1]) / 2 : i + 1 < arr.length ? (arr[i + 1] - arr[i]) / 2 : 0.5; + const hi = i + 1 < arr.length ? (arr[i + 1] - arr[i]) / 2 : lo; + return [arr[i] - lo, arr[i] + hi]; + }; + const cells = svgEl("g", { "data-role": "safety-map", opacity: 0.8 }); + grid.z.forEach((_z, iz) => { + grid.x.forEach((_x, ix) => { + const band = bandIndex(grid.fs[iz]?.[ix], analysis.required); + if (band === null) return; + const [x0, x1] = half(grid.x, ix).sort((a, b) => a - b); + const [z0, z1] = half(grid.z, iz).sort((a, b) => a - b); + const cell = svgEl("rect", { + x: x0, + y: z0, + width: x1 - x0, + height: z1 - z0, + class: `e03-cell e03-band-${band}`, + "data-fs": String(grid.fs[iz][ix]), + }); + cells.append(cell); + }); + }); + world.append(cells); + } + if (grid && toggles.centers) { + const dots = svgEl("g", { "data-role": "centers" }); + grid.z.forEach((z) => + grid.x.forEach((x) => + dots.append(svgEl("circle", { cx: x, cy: z, r: 1.6 * unit, class: "e03-gridpt" })), + ), + ); + world.append(dots); + } + // 노면 하중 — 지표 위 띠 + 아래 화살 눈금 + for (const load of view.surcharge) { + const zTop = Math.max( + ...view.surface.filter((p) => p[0] >= load.x0 && p[0] <= load.x1).map((p) => p[1]), + ); + if (!Number.isFinite(zTop)) continue; + const up = zTop + 14 * unit; + const g = svgEl("g", { class: "e03-load", "data-role": "surcharge" }); + g.append( + svgEl("polyline", { + points: pointsText([ + [load.x0, up], + [load.x1, up], + ]), + ...ns, + }), + ); + const n = Math.max(2, Math.round((load.x1 - load.x0) / (10 * unit))); + for (let i = 0; i <= n; i += 1) { + const x = load.x0 + ((load.x1 - load.x0) * i) / n; + g.append( + svgEl("polyline", { + points: pointsText([ + [x, up], + [x, zTop + 3 * unit], + ]), + ...ns, + }), + ); + } + world.append(g); + } + world.append( + svgEl("polyline", { points: pointsText(view.surface), class: "e03-surface", ...ns }), + ); + const water = analysis ? view.water[analysis.condition] : null; + if (water) { + world.append( + svgEl("polyline", { + points: pointsText(water), + class: "e03-water", + "data-role": "water", + ...ns, + }), + ); + } + const critical = analysis?.critical ?? null; + if (analysis && toggles.candidates) { + for (const c of analysis.candidates) { + if (critical && c.method === critical.method && c.kind === critical.kind) continue; + const line = svgEl("polyline", { + points: pointsText(c.points), + class: "e03-candidate", + "data-role": "candidate", + ...ns, + }); + line.append( + svgEl( + "title", + {}, + `${t(`E03_Method_${c.method}` as never)} · ${c.kind} · Fs ${fmt(c.fs)}`, + ), + ); + world.append(line); + } + } + const labels = svgEl("g", { "data-role": "labels" }); + if (critical && analysis) { + const slices = svgEl("g", { "data-role": "slices" }); + analysis.slices.forEach((slice, i) => { + const poly = svgEl("polygon", { + points: pointsText(sliceShape(slice, view.surface, critical.points)), + class: `e03-slice${draw.picked === i ? " is-picked" : ""}`, + "data-i": String(i), + ...ns, + }); + poly.addEventListener("click", (ev) => { + ev.stopPropagation(); + onPick(draw.picked === i ? null : i); + }); + slices.append(poly); + }); + world.append(slices); + if (critical.center && critical.entry && critical.exit) { + const c = critical.center; + world.append( + svgEl("polyline", { + points: pointsText([critical.entry, c, critical.exit]), + class: "e03-radius", + ...ns, + }), + svgEl("circle", { cx: c[0], cy: c[1], r: 3 * unit, class: "e03-gridpt" }), + ); + } + world.append( + svgEl("polyline", { + points: pointsText(critical.points), + class: "e03-critical", + "data-role": "critical", + ...ns, + }), + ); + // 글 — 안전율 · 중심 · 반경(화면 좌표 · 뒤집지 않음) + const anchor: Point = + critical.center ?? critical.points[Math.floor(critical.points.length / 2)]; + const [ax, ay] = toScreen(fit, anchor); + labels.append( + svgEl( + "text", + { + x: ax, + y: ay, + dx: "0.6em", + dy: "-0.7em", + class: "e03-label e03-label--fs", + "data-role": "fs-label", + }, + `Fs = ${fmt(critical.fs)}`, + ), + ); + if (critical.center) { + labels.append( + svgEl( + "text", + { x: ax, y: ay, dx: "0.7em", dy: "0.5em", class: "e03-label" }, + `Xc ${fmt(critical.center[0], 2)} · Zc ${fmt(critical.center[1], 2)} · R ${fmt(critical.radius, 2)} m`, + ), + ); + } + } + for (const s of draw.supports ?? []) { + const g = svgEl("g", { + class: `e03-support e03-support--${s.mode}`, + "data-role": "support", + "data-i": String(s.index), + "data-mode": s.mode, + }); + g.append( + svgEl("polyline", { points: pointsText(s.bond), class: "e03-support__bond", ...ns }), + svgEl("polyline", { + points: pointsText([s.head, s.tail]), + class: "e03-support__bar", + ...ns, + }), + svgEl("rect", { + x: s.head[0] - 1.2 * unit, + y: s.head[1] - 1.2 * unit, + width: 2.4 * unit, + height: 2.4 * unit, + class: "e03-support__head", + }), + ); + if (s.cross) + g.append( + svgEl("circle", { + cx: s.cross[0], + cy: s.cross[1], + r: 1.5 * unit, + class: "e03-support__cross", + "data-role": "support-cross", + ...ns, + }), + ); + g.append( + svgEl( + "title", + {}, + `${s.index + 1} · ${t(`E03_Sp_Mode_${s.mode}` as never)}` + + (s.force === null ? "" : ` · ${fmt(s.force, 1)} kN/m`), + ), + ); + world.append(g); + } + layer.append(world, labels); + svg.append(layer); + svg.addEventListener("click", () => draw.picked !== null && onPick(null)); + root.append(svg); + + // 마우스 아래 offset · 표고 (줌 상태 되돌려 셈) + svg.addEventListener("mousemove", (ev) => { + const box = svg.getBoundingClientRect(); + const sx = ((ev.clientX - box.left) / box.width) * width; + const sy = ((ev.clientY - box.top) / box.height) * height; + const s = zoom ?? { scale: 1, tx: 0, ty: 0 }; + const [x, z] = toModel(fit, [(sx - s.tx) / s.scale, (sy - s.ty) / s.scale]); + tip.textContent = `${t("E03_Offset")} ${x.toFixed(2)} m · EL ${z.toFixed(2)} m`; + tip.style.display = "block"; + tip.style.left = `${ev.clientX - box.left + 12}px`; + tip.style.top = `${ev.clientY - box.top + 12}px`; + }); + svg.addEventListener("mouseleave", () => (tip.style.display = "none")); + + const key = `${view.key}|${analysis?.condition}|${analysis?.side}|${width}x${height}`; + if (key !== zoomKey) zoom = undefined; + zoomKey = key; + attachSvgZoomPan({ + svg, + layer, + widthPx: width, + heightPx: height, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, + wheelZoom: true, + maxScale: MAX_SCALE, + home: focusHome(fit, view, analysis, width, height, MAX_SCALE), + initial: zoom, + onChange: (state) => { + zoom = state; + labels.style.setProperty("--e03-label-k", String(1 / state.scale)); // 글자는 확대 안 함 + }, + }); + }; + + new ResizeObserver(() => last && paint()).observe(root); + return { + root, + render(draw) { + last = draw; + paint(); + }, + }; +} diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css new file mode 100644 index 000000000..c7bbb4419 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -0,0 +1,690 @@ +/* ============================================================================= + * E03_StabSection_UI_Style.css + * E03 2D 측점 해석 — 판정 색(상태 · 아이콘 + 글과 함께) · 등안전율 띠(빨강 ↔ 회색 ↔ 파랑) · + * 지층 색(정해진 차례) · 밝음 · 어두움 각각 고른 값 + * ========================================================================== */ + +:root { + --e03-ok: #0ca30c; + --e03-ng: #d03b3b; + --e03-none: #9a9a95; + --e03-band-0: #a31f1f; + --e03-band-1: #e34948; + --e03-band-2: #f29b97; + --e03-band-3: #e6e4df; + --e03-band-4: #9ec5f4; + --e03-band-5: #3987e5; + --e03-band-6: #184f95; + --e03-mat-0: #2a78d6; + --e03-mat-1: #eb6834; + --e03-mat-2: #1baf7a; + --e03-mat-3: #eda100; + --e03-mat-4: #e87ba4; + --e03-mat-5: #008300; + --e03-mat-6: #6250d6; + --e03-mat-7: #e34948; + --e03-water: #1c74d9; + --e03-critical: #0b0b0b; + --e03-surface-line: #52514e; + --e03-plot: #fcfcfb; +} + +:root[data-theme="dark"] { + --e03-band-0: #c43c3c; + --e03-band-1: #e66767; + --e03-band-2: #8f4a48; + --e03-band-3: #383835; + --e03-band-4: #1c5cab; + --e03-band-5: #3987e5; + --e03-band-6: #86b6ef; + --e03-mat-0: #3987e5; + --e03-mat-1: #d95926; + --e03-mat-2: #199e70; + --e03-mat-3: #c98500; + --e03-mat-4: #d55181; + --e03-mat-5: #008300; + --e03-mat-6: #9085e9; + --e03-mat-7: #e66767; + --e03-water: #6da7ec; + --e03-critical: #f0efec; + --e03-surface-line: #c3c2b7; + --e03-plot: #1a1a19; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --e03-band-0: #c43c3c; + --e03-band-1: #e66767; + --e03-band-2: #8f4a48; + --e03-band-3: #383835; + --e03-band-4: #1c5cab; + --e03-band-5: #3987e5; + --e03-band-6: #86b6ef; + --e03-mat-0: #3987e5; + --e03-mat-1: #d95926; + --e03-mat-2: #199e70; + --e03-mat-3: #c98500; + --e03-mat-4: #d55181; + --e03-mat-5: #008300; + --e03-mat-6: #9085e9; + --e03-mat-7: #e66767; + --e03-water: #6da7ec; + --e03-critical: #f0efec; + --e03-surface-line: #c3c2b7; + --e03-plot: #1a1a19; + } +} + +/* ---- 틀 ---- */ +.e03-page { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + gap: var(--spacing-8); + height: calc(100dvh - var(--app-header-h)); + padding: var(--spacing-8) var(--spacing-12); + box-sizing: border-box; + min-height: 0; +} + +.e03-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 420px); + grid-template-rows: minmax(0, 1fr); + gap: var(--spacing-8); + min-height: 0; +} + +.e03-card { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; + min-height: 0; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.e03-card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + flex-wrap: wrap; +} + +.e03-card__title { + margin: 0; + color: var(--color-text); + font-size: var(--text-body); + font-weight: var(--font-weight-medium); +} + +.e03-muted { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.e03-empty { + margin: auto; + color: var(--color-text-secondary); + font-size: var(--text-body); + text-align: center; + white-space: pre-line; +} + +/* ---- 좌측 측점 목록 ---- */ +.e03-panel { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; + height: 100%; +} + +.e03-panel__row { + display: flex; + gap: var(--spacing-8); + align-items: center; + flex-wrap: wrap; +} + +.e03-panel__row > .ui-field { + flex: 1 1 0; + min-width: 0; +} + +.e03-check { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); + color: var(--color-text-body); + font-size: var(--text-body-sm); + cursor: pointer; + user-select: none; +} + +.e03-list { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + border-top: 1px solid var(--color-border); +} + +.e03-list table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-body-sm); + color: var(--color-text-body); +} + +.e03-list th { + position: sticky; + top: 0; + z-index: 1; + padding: var(--spacing-4); + background: var(--color-surface-raised); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + text-align: left; + white-space: nowrap; +} + +.e03-list td { + padding: 3px var(--spacing-4); + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} + +.e03-list td.is-num, +.e03-list th.is-num, +.e03-table td.is-num, +.e03-table th.is-num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.e03-list tbody tr { + cursor: pointer; +} + +.e03-list tbody tr:hover { + background: var(--color-surface-sunken); +} + +.e03-list tbody tr.is-active { + background: var(--color-surface-sunken); + outline: 2px solid var(--color-accent); + outline-offset: -2px; + font-weight: var(--font-weight-medium); +} + +.e03-verdict { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: var(--font-weight-medium); + color: var(--color-text); +} + +.e03-verdict::before { + content: ""; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--e03-none); +} + +.e03-verdict--ok::before { + background: var(--e03-ok); +} + +.e03-verdict--ng::before { + background: var(--e03-ng); + border-radius: 2px; + transform: rotate(45deg); +} + +.e03-star { + border: none; + background: none; + padding: 0 2px; + color: var(--color-text-muted); + font-size: 15px; + line-height: 1; + cursor: pointer; +} + +.e03-star.is-on { + color: var(--color-warning); +} + +.e03-star.is-user { + text-decoration: underline; +} + +.e03-star:disabled { + cursor: default; +} + +/* ---- 평면 띠 ---- */ +.e03-plan svg { + display: block; + width: 100%; + height: 96px; + background: var(--e03-plot); + border-radius: var(--radius-sm); +} + +.e03-plan__seg { + fill: none; + stroke-width: 7; + stroke-linecap: butt; + stroke-linejoin: round; + cursor: pointer; +} + +.e03-plan__seg--ok { + stroke: var(--e03-ok); +} + +.e03-plan__seg--ng { + stroke: var(--e03-ng); +} + +.e03-plan__seg--none { + stroke: var(--e03-none); +} + +.e03-plan__pick { + fill: var(--e03-plot); + stroke: var(--e03-critical); + stroke-width: 2; +} + +.e03-legend { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-12); + align-items: center; + color: var(--color-text-body); + font-size: var(--text-caption, 12px); +} + +.e03-legend__item { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} + +.e03-legend__swatch { + width: 14px; + height: 10px; + border-radius: 2px; + border: 1px solid var(--color-border); +} + +/* ---- 횡단 ---- */ +.e03-section { + flex: 1 1 auto; + min-height: 0; + position: relative; +} + +.e03-section svg { + display: block; + width: 100%; + height: 100%; + background: var(--e03-plot); + border-radius: var(--radius-sm); +} + +.e03-region { + fill-opacity: 0.32; + stroke: var(--e03-surface-line); + stroke-opacity: 0.45; + stroke-width: 0.6; +} + +.e03-surface { + fill: none; + stroke: var(--e03-surface-line); + stroke-width: 1.6; +} + +.e03-water { + fill: none; + stroke: var(--e03-water); + stroke-width: 1.6; + stroke-dasharray: 8 4; +} + +.e03-load { + stroke: var(--color-text-secondary); + stroke-width: 1; + fill: none; +} + +.e03-cell { + stroke: none; +} + +.e03-gridpt { + fill: var(--color-text-secondary); +} + +.e03-candidate { + fill: none; + stroke: var(--color-text-secondary); + stroke-opacity: 0.55; + stroke-width: 1.2; + stroke-dasharray: 4 3; +} + +.e03-critical { + fill: none; + stroke: var(--e03-critical); + stroke-width: 3; +} + +.e03-radius { + stroke: var(--e03-critical); + stroke-width: 1; + stroke-dasharray: 3 3; + fill: none; +} + +.e03-slice { + fill: transparent; + stroke: var(--color-text-secondary); + stroke-opacity: 0.35; + stroke-width: 0.6; + cursor: pointer; +} + +.e03-slice:hover { + fill: var(--color-accent); + fill-opacity: 0.18; +} + +.e03-slice.is-picked { + fill: var(--color-accent); + fill-opacity: 0.35; + stroke: var(--color-accent); + stroke-opacity: 1; + stroke-width: 1.5; +} + +.e03-label { + fill: var(--color-text); + font-size: calc(12px * var(--e03-label-k, 1)); + paint-order: stroke; + stroke: var(--e03-plot); + stroke-width: calc(3px * var(--e03-label-k, 1)); +} + +.e03-label--fs { + font-size: calc(14px * var(--e03-label-k, 1)); + font-weight: 700; +} + +.e03-axis line { + stroke: var(--color-border); +} + +.e03-axis text { + fill: var(--color-text-secondary); + font-size: 11px; +} + +.e03-tip { + position: absolute; + pointer-events: none; + padding: 2px 6px; + border-radius: var(--radius-sm); + background: var(--color-surface); + border: 1px solid var(--color-border); + color: var(--color-text); + font-size: var(--text-body-sm); + white-space: nowrap; + display: none; +} + +/* ---- 우측 결과 ---- */ +.e03-result { + overflow: auto; +} + +.e03-kpi { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-4) var(--spacing-12); + margin: 0; + font-size: var(--text-body-sm); +} + +.e03-kpi dt { + color: var(--color-text-secondary); +} + +.e03-kpi dd { + margin: 0; + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +.e03-kpi dd.is-big { + font-size: 22px; + font-weight: 700; +} + +.e03-table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-body-sm); + color: var(--color-text-body); +} + +.e03-table th { + position: sticky; + top: 0; + background: var(--color-surface-raised); + padding: 2px var(--spacing-4); + border-bottom: 1px solid var(--color-border); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.e03-table td { + padding: 2px var(--spacing-4); + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} + +.e03-table tbody tr { + cursor: pointer; +} + +.e03-table tbody tr.is-picked { + background: var(--color-surface-sunken); + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} + +.e03-slices { + max-height: 220px; + overflow: auto; +} + +.e03-fbd svg, +.e03-graph svg { + display: block; + width: 100%; + background: var(--e03-plot); + border-radius: var(--radius-sm); +} + +.e03-arrow { + stroke-width: 2; + fill: none; +} + +.e03-arrow--W, +.e03-arrow--Q { + stroke: var(--e03-mat-6); +} + +.e03-arrow--N { + stroke: var(--e03-mat-0); +} + +.e03-arrow--U { + stroke: var(--e03-water); + stroke-dasharray: 4 2; +} + +.e03-arrow--S { + stroke: var(--e03-mat-1); +} + +.e03-arrow--EL, +.e03-arrow--ER, +.e03-arrow--XL, +.e03-arrow--XR { + stroke: var(--e03-mat-2); +} + +.e03-line { + fill: none; + stroke-width: 2; +} + +.e03-line--sigma { + stroke: var(--e03-mat-0); +} + +.e03-line--u { + stroke: var(--e03-water); + stroke-dasharray: 5 3; +} + +.e03-line--tau { + stroke: var(--e03-mat-1); +} + +/* ---- 3D 보기 ---- */ +.e03-view3d { + position: fixed; + inset: 5vh 5vw; + z-index: 800; + display: flex; + flex-direction: column; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.e03-view3d__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-12); +} + +.e03-view3d__canvas { + flex: 1 1 auto; + min-height: 0; + position: relative; +} + +.e03-view3d__canvas canvas { + display: block; + width: 100%; + height: 100%; +} + +/* ---- 색 칸(지층 · 등안전율 띠) ---- */ +.e03-mat-0 { + fill: var(--e03-mat-0); + background: var(--e03-mat-0); +} + +.e03-mat-1 { + fill: var(--e03-mat-1); + background: var(--e03-mat-1); +} + +.e03-mat-2 { + fill: var(--e03-mat-2); + background: var(--e03-mat-2); +} + +.e03-mat-3 { + fill: var(--e03-mat-3); + background: var(--e03-mat-3); +} + +.e03-mat-4 { + fill: var(--e03-mat-4); + background: var(--e03-mat-4); +} + +.e03-mat-5 { + fill: var(--e03-mat-5); + background: var(--e03-mat-5); +} + +.e03-mat-6 { + fill: var(--e03-mat-6); + background: var(--e03-mat-6); +} + +.e03-mat-7 { + fill: var(--e03-mat-7); + background: var(--e03-mat-7); +} + +.e03-band-0 { + fill: var(--e03-band-0); + background: var(--e03-band-0); +} + +.e03-band-1 { + fill: var(--e03-band-1); + background: var(--e03-band-1); +} + +.e03-band-2 { + fill: var(--e03-band-2); + background: var(--e03-band-2); +} + +.e03-band-3 { + fill: var(--e03-band-3); + background: var(--e03-band-3); +} + +.e03-band-4 { + fill: var(--e03-band-4); + background: var(--e03-band-4); +} + +.e03-band-5 { + fill: var(--e03-band-5); + background: var(--e03-band-5); +} + +.e03-band-6 { + fill: var(--e03-band-6); + background: var(--e03-band-6); +} + +.e03-swatch-none { + background: var(--e03-none); +} diff --git a/E03_StabSection/E03_StabSection_UI_Support.css b/E03_StabSection/E03_StabSection_UI_Support.css new file mode 100644 index 000000000..bc2f77b62 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Support.css @@ -0,0 +1,62 @@ +/* E03 보강 검토 네일 · 앵커 선(536-3) — 머리 · 정착 구간 · 활동면과 만나는 점 · 결과 mode 색 */ +.e03-support { + --e03-support: #6b6b6b; + stroke: var(--e03-support); + fill: none; +} + +.e03-support--tensile { + --e03-support: #e34948; +} + +.e03-support--pullout { + --e03-support: #eda100; +} + +.e03-support--stripping { + --e03-support: #6250d6; +} + +.e03-support--prestress { + --e03-support: #1c74d9; +} + +.e03-support--not_crossing { + --e03-support: #9a9a9a; +} + +.e03-support__bar { + stroke-width: 1.5; +} + +.e03-support__bond { + stroke-width: 4; + stroke-opacity: 0.55; +} + +.e03-support__head { + fill: var(--e03-support); + stroke: none; +} + +.e03-support__cross { + fill: var(--e03-plot); + stroke: var(--e03-support); + stroke-width: 1.5; +} + +.e03-support-swatch { + display: inline-block; + width: 14px; + height: 4px; + margin-right: 4px; + vertical-align: middle; + background: var(--e03-support); +} + +.e03-support-fields { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: var(--spacing-8); + align-items: end; +} diff --git a/E03_StabSection/E03_StabSection_UI_Support.ts b/E03_StabSection/E03_StabSection_UI_Support.ts new file mode 100644 index 000000000..07c8f829b --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Support.ts @@ -0,0 +1,300 @@ +/* ============================================================================= + * E03_StabSection_UI_Support.ts + * E03 「보강 검토」 탭 네일 · 앵커 가정(PLAN 536-3 뒷부분) — 종류 · 배치(행 수 · 첫 행 높이 · 수직 · 수평 간격) · + * 제원 → supports 목록(`layoutSupports` · 입력 꼴 그대로 POST …/stab/whatif/{측점}) · 최소 길이 찾기 · + * 전후 안전율 표 · 네일마다 동원력 · mode 표 · 후 단면 그림(머리 · 정착 구간 · 활동면과 만나는 점 · mode 색). + * 기본값 = 국내 흔한 값 후보(사용자 확정 전) · 계산은 서버. + * ========================================================================== */ + +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { slopeTable } from "./E03_StabSection_UI_Reinforce"; +import { createSectionCanvas } from "./E03_StabSection_UI_Section"; +import type { Condition, SectionView, SideCase, StationRow } from "./logic/E03_StabSection_Logic"; +import { + layoutSupports, + supportDraws, + SUPPORT_DEFAULTS, + type SupportForce, + type SupportIn, + type SupportLayout, + type SupportType, +} from "./logic/E03_StabSection_Support"; + +export interface SupportRecord { + type: "support"; + station_key: string; + assume: { side: string; role: "cut" | "fill"; supports: SupportIn[] }; + search: { + found: number | null; + tried: [number, boolean][]; + longest: number; + condition: Condition | null; + } | null; + before: Partial>; + after: Partial>; + support_forces: Partial> | null; + supports_pending: boolean; + elapsed_s: number; + view: SectionView; +} + +export interface SupportAsk { + side: string; + ratio: null; + bench_w: 0; + bench_h: 0; + search: boolean; + condition: Condition | null; + supports: SupportIn[]; +} + +export interface SupportOptions { + row: StationRow; + /** 지금 측점 단면(원본) — 비탈 면 · 활동 방향 */ + view: SectionView; + condition: Condition; + side: string | null; + editable: boolean; + busy: boolean; + record: SupportRecord | undefined; + run: (ask: SupportAsk) => void; +} + +/** 마지막 배치 — 다시 그려도(계산 중 · 측점 바꿈) 입력 그대로 */ +let layout: SupportLayout = { ...SUPPORT_DEFAULTS.nail }; + +type NumKey = Exclude; +const FIELDS: NumKey[] = [ + "rows", + "first_m", + "v_spacing_m", + "h_spacing_m", + "length_m", + "angle_deg", + "tensile_kN", + "bond_kPa", + "bond_length_m", + "hole_dia_mm", + "prestress_kN", +]; +const ANCHOR_ONLY: NumKey[] = ["bond_length_m", "prestress_kN"]; +const MODES = ["tensile", "pullout", "stripping", "prestress", "not_crossing"] as const; + +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined || !Number.isFinite(v) ? "-" : v.toFixed(d); + +function assumeText(rec: SupportRecord): string { + const s = rec.assume.supports; + const first = s[0]; + if (!first) return ""; + let text = + `${t(`E03_Sp_${first.type}` as LocaleKey)} ${s.length}${t("E03_Sp_Count")} · ` + + `L ${fmt(first.length_m, 1)} m · ${first.angle_deg}° · @${first.spacing_m} m`; + if (rec.search) + text += rec.search.found + ? ` · ${t("E03_Sp_Found")} (${rec.search.tried.length}${t("E03_Iter")})` + : ` · ${t("E03_Sp_NotFound").replace("{n}", String(rec.search.longest))}`; + return `${text} · ${rec.elapsed_s} s`; +} + +function forceTable(rec: SupportRecord, condition: Condition): HTMLElement { + const forces = rec.support_forces?.[condition] ?? null; + const table = el("table", { className: "e03-table", attrs: { "data-role": "support-table" } }); + const head = ["E03_Sp_No", "E03_Sp_Type", "E03_Sp_Head", "E03_Sp_Length", "E03_Sp_Force"]; + table.append( + el("thead", { + children: [ + el("tr", { + children: [ + ...head.map((k, i) => + el("th", { className: i > 1 ? "is-num" : "", text: t(k as LocaleKey) }), + ), + el("th", { text: t("E03_Sp_Mode") }), + ], + }), + ], + }), + ); + const body = el("tbody"); + rec.assume.supports.forEach((s, i) => { + const f = forces?.find((v) => v.index === i); + const mode = f?.mode ?? "pending"; + body.append( + el("tr", { + attrs: { "data-i": String(i), "data-mode": mode }, + children: [ + el("td", { text: String(i + 1) }), + el("td", { text: t(`E03_Sp_${s.type}` as LocaleKey) }), + el("td", { className: "is-num", text: `${fmt(s.x)} / ${fmt(s.z)}` }), + el("td", { className: "is-num", text: fmt(s.length_m, 1) }), + el("td", { className: "is-num", text: fmt(f?.mobilized_kN_per_m, 1) }), + el("td", { + children: [ + el("span", { + className: `e03-support e03-support--${mode}`, + children: [el("span", { className: "e03-support-swatch" })], + }), + el("span", { text: t(`E03_Sp_Mode_${mode}` as LocaleKey) }), + ], + }), + ], + }), + ); + }); + table.append(body); + return table; +} + +function legend(): HTMLElement { + const box = el("p", { className: "e03-muted", attrs: { "data-role": "support-legend" } }); + for (const mode of MODES) + box.append( + el("span", { + className: `e03-support e03-support--${mode}`, + children: [el("span", { className: "e03-support-swatch" })], + }), + el("span", { text: `${t(`E03_Sp_Mode_${mode}` as LocaleKey)} ` }), + ); + return box; +} + +export function buildSupport(o: SupportOptions): HTMLElement { + const host = el("div", { className: "e03-card", attrs: { "data-role": "support" } }); + const sides = o.row.sides; + const sideField = createSelectField({ + label: t("E03_Side"), + options: sides.map((s) => ({ + value: s.side, + text: `${t(s.side === "left" ? "E03_Left" : "E03_Right")} · ${t(s.role === "cut" ? "E03_Cut" : "E03_Fill")}`, + })), + value: o.record?.assume.side ?? o.side ?? undefined, + compact: true, + }); + const typeField = createSelectField({ + label: t("E03_Sp_Type"), + options: (["nail", "anchor"] as SupportType[]).map((v) => ({ + value: v, + text: t(`E03_Sp_${v}` as LocaleKey), + })), + value: layout.type, + compact: true, + }); + typeField.select.dataset.role = "support-type"; + const inputs = new Map(); + const fields = el("div", { className: "e03-support-fields" }); + fields.append(sideField.root, typeField.root); + for (const key of FIELDS) { + const f = createInputField({ + label: t(`E03_Sp_F_${key}` as LocaleKey), + type: "number", + min: 0, + compact: true, + value: String(layout[key]), + }); + f.input.step = key === "rows" ? "1" : "0.1"; + f.input.dataset.field = key; + f.input.disabled = layout.type === "nail" && ANCHOR_ONLY.includes(key); + f.input.addEventListener("input", () => { + const v = Number(f.input.value); + if (f.input.value.trim() !== "" && Number.isFinite(v)) layout = { ...layout, [key]: v }; + }); + inputs.set(key, f.input); + fields.append(f.root); + } + typeField.select.addEventListener("change", () => { + layout = { ...SUPPORT_DEFAULTS[typeField.select.value as SupportType] }; + for (const [key, input] of inputs) { + input.value = String(layout[key]); + input.disabled = layout.type === "nail" && ANCHOR_ONLY.includes(key); + } + }); + + const ask = (search: boolean): SupportAsk | null => { + const side = sideField.select.value; + const info = sides.find((s) => s.side === side); + if (!info) return null; + const supports = layoutSupports(o.view.surface, side, info.role, info.height_m, layout); + return { + side, + ratio: null, + bench_w: 0, + bench_h: 0, + search, + condition: search ? o.condition : null, + supports, + }; + }; + const send = (search: boolean): void => { + const a = ask(search); + if (a && a.supports.length) o.run(a); + }; + const run = createButton({ + label: o.busy ? t("E03_Rf_Busy") : t("E03_Sp_Run"), + variant: "filled", + onClick: () => send(false), + }); + const find = createButton({ + label: t("E03_Sp_Search"), + variant: "ghost", + onClick: () => send(true), + }); + run.disabled = find.disabled = o.busy || !o.editable; + run.dataset.role = "support-run"; + find.dataset.role = "support-search"; + const buttons = el("div", { className: "e03-panel__row" }); + buttons.append(find, run); + host.append( + el("h3", { className: "e03-card__title", text: t("E03_Sp_Title") }), + el("p", { className: "e03-muted", text: t("E03_Sp_Hint") }), + el("p", { + className: "e03-muted", + text: t("E03_Sp_Source"), + attrs: { "data-role": "support-source" }, + }), + fields, + buttons, + ); + const rec = o.record; + if (!rec) return host; + host.append( + el("p", { + className: "e03-muted", + text: assumeText(rec), + attrs: { "data-role": "support-assume" }, + }), + ); + if (rec.supports_pending) + host.append( + el("p", { + className: "e03-muted", + text: t("E03_Sp_Pending"), + attrs: { "data-role": "support-pending" }, + }), + ); + host.append(slopeTable(rec), forceTable(rec, o.condition), legend()); + const canvas = createSectionCanvas(() => undefined); + canvas.root.style.height = "260px"; + canvas.root.style.flex = "none"; + canvas.root.dataset.role = "support-section"; + host.append(canvas.root); + const analyses = rec.view.analyses.filter((a) => a.condition === o.condition); + const analysis = analyses.find((a) => a.side === rec.assume.side) ?? analyses[0] ?? null; + const direction = + rec.view.analyses.find((a) => a.side === rec.assume.side)?.direction ?? + (rec.assume.side === "left" ? -1 : 1); + const draw = { + view: rec.view, + analysis, + toggles: { map: false, centers: false, candidates: false }, + picked: null, + supports: supportDraws( + rec.assume.supports, + direction, + analysis?.critical?.points ?? null, + rec.support_forces?.[o.condition], + ), + }; + canvas.render(draw); // 붙기 전 = 기본 폭 · 붙으면 ResizeObserver 가 다시 그림 + return host; +} diff --git a/E03_StabSection/E03_StabSection_UI_View3D.ts b/E03_StabSection/E03_StabSection_UI_View3D.ts new file mode 100644 index 000000000..4042d0d22 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_View3D.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * E03_StabSection_UI_View3D.ts + * E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만). + * 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) · + * 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀. + * ========================================================================== */ + +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { createButton, el } from "@ui/ui_template_elements"; +import { t } from "@ui/ui_template_locale"; +import { + bandIndex, + RATIO_BREAKS, + type Condition, + type StationRow, +} from "./logic/E03_StabSection_Logic"; + +interface Corridor3d { + origin: [number, number, number]; + ribbons: { + kind: string; + side: string; + colCount: number; + chainages: number[]; + positions: number[]; + }[]; +} + +/** 측점 하나 그 조건 최소 비(안전율 ÷ 기준)의 띠 */ +function stationBand(row: StationRow, condition: Condition): number | null { + let best: number | null = null; + for (const c of Object.values(row.cases[condition] ?? {})) { + const band = bandIndex(c.fs, c.required); + if (band !== null && (best === null || band < best)) best = band; + } + return best; +} + +function cssColor(name: string): THREE.Color { + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return new THREE.Color(value || "#888888"); +} + +export async function openView3d( + data: Corridor3d, + rows: StationRow[], + condition: Condition, + conditionLabel: string, +): Promise { + const shell = el("div", { className: "e03-view3d", attrs: { "data-role": "view3d" } }); + const head = el("div", { className: "e03-view3d__head" }); + const legend = el("div", { className: "e03-legend" }); + legend.append( + el("span", { className: "e03-muted", text: `${conditionLabel} · ${t("E03_View3d_Legend")}` }), + ); + const edges = [null, ...RATIO_BREAKS, null]; + for (let i = 0; i <= RATIO_BREAKS.length; i += 1) { + const item = el("span", { className: "e03-legend__item" }); + const text = + edges[i] === null + ? `< ${edges[i + 1]}` + : edges[i + 1] === null + ? `≥ ${edges[i]}` + : `${edges[i]}–${edges[i + 1]}`; + item.append(el("span", { className: `e03-legend__swatch e03-band-${i}` }), text); + legend.append(item); + } + const noneItem = el("span", { className: "e03-legend__item" }); + noneItem.append( + el("span", { className: "e03-legend__swatch e03-swatch-none" }), + t("E03_Verdict_none"), + ); + legend.append(noneItem); + const close = createButton({ label: t("Common_Btn_Close"), variant: "ghost", size: "sm" }); + head.append(el("h3", { className: "e03-card__title", text: t("E03_View3d") }), legend, close); + const host = el("div", { className: "e03-view3d__canvas" }); + shell.append(head, host); + document.body.append(shell); + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + host.append(renderer.domElement); + const scene = new THREE.Scene(); + scene.background = cssColor("--e03-plot"); + scene.add(new THREE.AmbientLight(0xffffff, 0.75)); + const sun = new THREE.DirectionalLight(0xffffff, 0.9); + sun.position.set(200, 400, 300); + scene.add(sun); + + // 측점 누가 → 색 + const sorted = rows.slice().sort((a, b) => a.chainage_m - b.chainage_m); + const bandColors = [...Array(RATIO_BREAKS.length + 1).keys()].map((i) => + cssColor(`--e03-band-${i}`), + ); + const noneColor = cssColor("--e03-none"); + const colorAt = (chainage: number): THREE.Color => { + let lo = 0; + let hi = sorted.length - 1; + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (sorted[mid].chainage_m <= chainage) lo = mid; + else hi = mid; + } + const near = + sorted.length && + Math.abs(sorted[hi].chainage_m - chainage) < Math.abs(sorted[lo].chainage_m - chainage) + ? sorted[hi] + : sorted[lo]; + const band = near ? stationBand(near, condition) : null; + return band === null ? noneColor : bandColors[band]; + }; + + const group = new THREE.Group(); + for (const ribbon of data.ribbons) { + const rowsN = ribbon.chainages.length; + const cols = ribbon.colCount; + if (rowsN < 2 || cols < 2) continue; + const pos = new Float32Array(rowsN * cols * 3); + const col = new Float32Array(rowsN * cols * 3); + for (let r = 0; r < rowsN; r += 1) { + const c3 = colorAt(ribbon.chainages[r]); + for (let c = 0; c < cols; c += 1) { + const i = (r * cols + c) * 3; + const [x, y, z] = ribbon.positions.slice(i, i + 3); + pos.set([x, z, -y], i); + col.set([c3.r, c3.g, c3.b], i); + } + } + const index: number[] = []; + for (let r = 0; r + 1 < rowsN; r += 1) { + for (let c = 0; c + 1 < cols; c += 1) { + const a = r * cols + c; + index.push(a, a + cols, a + 1, a + 1, a + cols, a + cols + 1); + } + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(col, 3)); + geometry.setIndex(index); + geometry.computeVertexNormals(); + const material = new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }); + group.add(new THREE.Mesh(geometry, material)); + } + scene.add(group); + + const box = new THREE.Box3().setFromObject(group); + const sphere = box.getBoundingSphere(new THREE.Sphere()); + const camera = new THREE.PerspectiveCamera(45, 1, 0.5, Math.max(sphere.radius * 20, 1000)); + camera.position + .copy(sphere.center) + .add(new THREE.Vector3(0, sphere.radius * 1.9, sphere.radius * 1.6)); + const controls = new OrbitControls(camera, renderer.domElement); + controls.target.copy(sphere.center); + controls.update(); + + let frame = 0; + const resize = (): void => { + const w = Math.max(host.clientWidth, 1); + const h = Math.max(host.clientHeight, 1); + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + }; + const loop = (): void => { + frame = requestAnimationFrame(loop); + controls.update(); + renderer.render(scene, camera); + }; + const observer = new ResizeObserver(resize); + observer.observe(host); + resize(); + loop(); + (window as unknown as { __e03View3d?: unknown }).__e03View3d = { + meshes: group.children.length, + vertices: group.children.reduce( + (n, m) => n + ((m as THREE.Mesh).geometry.getAttribute("position").count ?? 0), + 0, + ), + }; + + const dispose = (): void => { + cancelAnimationFrame(frame); + observer.disconnect(); + controls.dispose(); + group.traverse((o) => { + if (o instanceof THREE.Mesh) { + o.geometry.dispose(); + (o.material as THREE.Material).dispose(); + } + }); + renderer.dispose(); + shell.remove(); + document.removeEventListener("keydown", onKey); + }; + const onKey = (ev: KeyboardEvent): void => { + if (ev.key === "Escape") dispose(); + }; + document.addEventListener("keydown", onKey); + close.addEventListener("click", dispose); +} diff --git a/E03_StabSection/E03_StabSection_UI_Walls.css b/E03_StabSection/E03_StabSection_UI_Walls.css new file mode 100644 index 000000000..13c7771c5 --- /dev/null +++ b/E03_StabSection/E03_StabSection_UI_Walls.css @@ -0,0 +1,23 @@ +/* E03 횡단 벽(옹벽 · 석축) 회색 빗금 · 목록 표시(532-5) */ +.e03-wall { + stroke: var(--e03-surface-line); + stroke-width: 1; +} + +.e03-wall-hatch__bg { + fill: #9a9a9a; + fill-opacity: 0.35; +} + +.e03-wall-hatch__line { + stroke: #6b6b6b; +} + +.e03-wall-swatch { + background: repeating-linear-gradient(45deg, #6b6b6b 0 2px, #c8c8c8 2px 5px); +} + +.e03-wall-tag { + margin-left: 4px; + color: var(--color-text-secondary); +} diff --git a/E03_StabSection/E03_StabSection_View.py b/E03_StabSection/E03_StabSection_View.py new file mode 100644 index 000000000..5cf3e5296 --- /dev/null +++ b/E03_StabSection/E03_StabSection_View.py @@ -0,0 +1,302 @@ +"""E03 2D 해석 결과 → 화면 꼴 (요구 345 · 347 · PLAN 532-3) — storage 를 모르는 순수 함수. + +엔진 출력 = `E03_StabSection_Engine_Station.analyze_station` 그대로(cases × methods). +판정 = 대표 방법(Bishop) 최소 안전율 ≥ 기준안전율(E02 `safety_factors[조건][깎기 · 쌓기]`). +대표 단면(보고서 몫) = 자동(쌓기 높이 ≥ 10 m · 깎기 ≥ 15 m · 최소 안전율 하위 5) + 사용자 켬/끔. +좌표 = 엔진 그대로(x = B06 offset · 좌 + · z = 표고) — 화면이 같은 좌표로 그림. +""" + +from __future__ import annotations + +import math +from typing import Any + +PRIMARY = "bishop" +CONDITIONS = ("dry", "wet", "seismic") +REPRESENTATIVE_FILL_M = 10.0 +REPRESENTATIVE_CUT_M = 15.0 +REPRESENTATIVE_LOWEST = 5 +#: 화면에 넘기는 면 꼭짓점 · 절편 자릿수 +_ROUND = 3 + + +def _r(value: Any, digits: int = _ROUND) -> Any: + if isinstance(value, float): + return round(value, digits) if math.isfinite(value) else None + if isinstance(value, list): + return [_r(v, digits) for v in value] + return value + + +def required_factor(factors: dict[str, Any], condition: str, role: str) -> float | None: + """기준안전율 — 조건 · 비탈 역할(cut · fill). 없으면 None.""" + value = (factors.get(condition) or {}).get(role) + return float(value) if isinstance(value, int | float) else None + + +def _governing(case: dict[str, Any]) -> dict[str, Any] | None: + """한 비탈 · 조건의 대표 방법 최소 면(원호 · 비원호 중 작은 쪽).""" + return ((case.get("result") or {}).get("methods") or {}).get(PRIMARY, {}).get("min") + + +def station_row( + meta: dict[str, Any], + out: dict[str, Any] | None, + factors: dict[str, Any], + error: str | None = None, +) -> dict[str, Any]: + """측점 목록 한 줄 — 조건 × 비탈 안전율 · 기준 · 판정 · 비탈 높이.""" + cases: dict[str, dict[str, Any]] = {} + sides: dict[str, dict[str, Any]] = {} + for case in (out or {}).get("cases", []): + cond, side, role = case["condition"], case["side"], case["role"] + sides.setdefault(side, {"side": side, "role": role, "height_m": _r(case.get("height_m"))}) + best = _governing(case) + fs = best.get("F") if best else None + need = required_factor(factors, cond, role) + cases.setdefault(cond, {})[side] = { + "fs": _r(fs), + "required": need, + "ok": None if fs is None or need is None else bool(fs >= need), + "kind": best.get("kind") if best else None, + "converged": best.get("converged") if best else None, + } + minimum: dict[str, float | None] = {} + verdict: dict[str, str] = {} + for cond, by_side in cases.items(): + values = [v["fs"] for v in by_side.values() if v["fs"] is not None] + minimum[cond] = min(values) if values else None + oks = [v["ok"] for v in by_side.values() if v["ok"] is not None] + verdict[cond] = "none" if not oks else ("ok" if all(oks) else "ng") + return { + "key": meta["key"], + "chainage_m": meta["chainage_m"], + "label": meta.get("label"), + "section_mode": meta.get("section_mode"), + "ground_type": meta.get("ground_type"), + "center": meta.get("center"), + "fp": meta.get("fp"), + "walls": meta.get("walls") or 0, + "sides": list(sides.values()), + "cases": cases, + "min": minimum, + "verdict": verdict, + "error": error, + "elapsed_s": (out or {}).get("elapsed_s"), + } + + +def overall_verdict(row: dict[str, Any], condition: str | None = None) -> str: + """측점 판정 — 조건 하나 또는 전 조건 중 가장 나쁜 것(ng > ok > none).""" + verdict = row.get("verdict") or {} + values = [verdict.get(condition, "none")] if condition else list(verdict.values()) + if "ng" in values: + return "ng" + return "ok" if "ok" in values else "none" + + +def auto_representative(rows: list[dict[str, Any]]) -> dict[str, list[str]]: + """자동 대표 단면 — 열쇠 → 까닭(fill_height · cut_height · lowest).""" + reasons: dict[str, list[str]] = {} + for row in rows: + for side in row["sides"]: + height = side.get("height_m") or 0.0 + if side["role"] == "fill" and height >= REPRESENTATIVE_FILL_M: + reasons.setdefault(row["key"], []).append("fill_height") + if side["role"] == "cut" and height >= REPRESENTATIVE_CUT_M: + reasons.setdefault(row["key"], []).append("cut_height") + ranked = sorted( + ( + (min(v for v in row["min"].values() if v is not None), row["key"]) + for row in rows + if any(v is not None for v in row["min"].values()) + ), + ) + for _, key in ranked[:REPRESENTATIVE_LOWEST]: + reasons.setdefault(key, []).append("lowest") + return {key: sorted(set(v)) for key, v in reasons.items()} + + +def mark_representative(rows: list[dict[str, Any]], overrides: dict[str, bool]) -> None: + """행마다 대표 단면 칸 — auto(까닭) · user(켬/끔 · 없으면 None) · on(최종).""" + auto = auto_representative(rows) + for row in rows: + user = overrides.get(row["key"]) + reasons = auto.get(row["key"], []) + row["representative"] = { + "auto": reasons, + "user": user, + "on": bool(reasons) if user is None else bool(user), + } + + +def governing_row(row: dict[str, Any]) -> dict[str, Any]: + """측점 줄 덧칸(E04 · E05 같은 규칙) — 지배 = 조건 × 비탈 중 기준 대비 가장 낮은 것(fs / 기준) · + fs_min = 그 fs · 조건별 안전율 = 그 비탈 · 판정 = 조건 하나라도 N.G 면 ng.""" + roles = {s["side"]: s["role"] for s in row.get("sides") or []} + cases = row.get("cases") or {} + worst = min( + ( + (c["fs"] / c["required"], side, c["fs"]) + for sides in cases.values() + for side, c in sides.items() + if c.get("fs") is not None and c.get("required") + ), + default=None, + ) + side = worst[1] if worst else None + verdicts = set((row.get("verdict") or {}).values()) + values = [v for v in (row.get("min") or {}).values() if v is not None] # 기준 없을 때 + return { + "station_key": row["key"], + "chainage_m": row["chainage_m"], + "side": roles.get(side), + "fs_min": worst[2] if worst else min(values, default=None), + "fs_by_condition": { + cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides + }, + "method": PRIMARY, + "verdict_overall": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None, + } + + +def summarize(rows: list[dict[str, Any]], conditions: list[str]) -> dict[str, Any]: + """머리 요약 — 조건별 최소 안전율 · N.G 측점 수.""" + out: dict[str, Any] = {"stations": len(rows), "errors": sum(1 for r in rows if r["error"])} + for cond in conditions: + values = [r["min"].get(cond) for r in rows if r["min"].get(cond) is not None] + out[cond] = { + "min_fs": min(values) if values else None, + "ng": sum(1 for r in rows if r["verdict"].get(cond) == "ng"), + } + return out + + +def _slices(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """절편표 + 자유물체도 힘 — N(전 수직력) = N' + u·l · U = u·l · 오른쪽 = 다음 절편의 왼쪽.""" + out = [] + for i, s in enumerate(rows): + nxt = rows[i + 1] if i + 1 < len(rows) else None + U = s["pore_pressure"] * s["base_length"] + out.append( + { + "i": i + 1, + **{k: _r(s.get(k)) for k in ("x_left", "x_right", "width", "height", "alpha_deg")}, + **{k: _r(s.get(k)) for k in ("weight", "surcharge", "pore_pressure")}, + **{k: _r(s.get(k)) for k in ("base_length", "c", "phi_deg")}, + "material": s.get("material"), + "N": _r(s["normal_eff"] + U), + "N_eff": _r(s["normal_eff"]), + "U": _r(U), + "S": _r(s["shear_mobilized"]), + "E_left": _r(s["E_left"]), + "E_right": _r(nxt["E_left"]) if nxt else 0.0, + "X_left": _r(s["X_left"]), + "X_right": _r(nxt["X_left"]) if nxt else 0.0, + } + ) + return out + + +def _surface(found: dict[str, Any] | None, method: str) -> dict[str, Any] | None: + if not found: + return None + out = { + "method": method, + "kind": found.get("kind"), + "fs": _r(found.get("F")), + "points": _r(found.get("points") or []), + "entry": _r(found.get("entry")), + "exit": _r(found.get("exit")), + "converged": found.get("converged"), + "iterations": found.get("iterations"), + } + if found.get("center") is not None: + out["center"] = _r(found["center"]) + out["radius"] = _r(found.get("radius")) + for key in ("lambda", "theta_deg", "F0", "f0"): + if key in found: + out[key] = _r(found[key]) + return out + + +def section_view( + row: dict[str, Any], + out: dict[str, Any], + model: dict[str, Any], + water: dict[str, list[list[float]] | None], +) -> dict[str, Any]: + """측점 하나 화면 — 지층 · 지표 · 지하수선 · 하중 · 비탈 × 조건 임계면 · 후보 · 절편 · 격자.""" + analyses = [] + for case in out.get("cases", []): + result = case.get("result") or {} + methods = result.get("methods") or {} + best = (methods.get(PRIMARY) or {}).get("min") + candidates = [] + for method, found in methods.items(): + for kind in ("circular", "noncircular"): + surface = _surface(found.get(kind), method) + if surface and surface["fs"] is not None: + candidates.append(surface) + side_row = (row["cases"].get(case["condition"]) or {}).get(case["side"]) or {} + contour = result.get("contour") + analyses.append( + { + "condition": case["condition"], + "side": case["side"], + "role": case["role"], + "height_m": _r(case.get("height_m")), + "limits": _r(list(case.get("limits") or [])), + "direction": case.get("direction"), + "required": side_row.get("required"), + "ok": side_row.get("ok"), + "min_by_method": {m: _r(v) for m, v in (case.get("min_F") or {}).items()}, + # 엄밀해(Spencer · M-P) 「해 없음」 면이 있는 방법 — 화면이 빈 값을 「수렴 안 됨」으로 + "no_solution": sorted( + m + for m, found in methods.items() + if any( + (found.get(k) or {}).get("status") == "no_solution" + for k in ("circular", "noncircular") + ) + ), + "critical": _surface(best, PRIMARY), + "candidates": candidates, + "slices": _slices(best.get("slices") or []) if best else [], + "grid": ( + { + "method": contour.get("method"), + "x": _r(contour["x"]), + "z": _r(contour["z"]), + "fs": _r(contour["F"]), + } + if contour + else None + ), + "counts": result.get("counts"), + } + ) + return { + "key": row["key"], + "chainage_m": row["chainage_m"], + "label": row.get("label"), + "materials": [ + {k: m.get(k) for k in ("id", "name", "gamma_t", "gamma_sat", "c", "phi")} + for m in model["materials"] + ], + "regions": [ + { + "material_id": r["material_id"], + "polygon": _r(r["polygon"]), + "holes": _r(r.get("holes") or []), + } + for r in model["regions"] + ], + "surface": _r(model["surface"]), + "bottom_z": _r(model.get("bottom_z")), + "surcharge": model.get("surcharge") or [], + "water": {cond: _r(line) if line else None for cond, line in water.items()}, + "areas": {k: _r(v) for k, v in (out.get("areas") or {}).items()}, + "analyses": analyses, + "row": row, + } diff --git a/E03_StabSection/E03_StabSection_WallCheck.py b/E03_StabSection/E03_StabSection_WallCheck.py new file mode 100644 index 000000000..e02dc0ce9 --- /dev/null +++ b/E03_StabSection/E03_StabSection_WallCheck.py @@ -0,0 +1,220 @@ +"""E03 벽(옹벽 · 석축) 외적 안정 — 활동 · 전도 · 지지력 (요구 345 · 351 · PLAN 536-3). + +storage 를 모르는 순수 함수. 벽 폴리곤(2D 모형 `walls` · x = B06 offset · z = 표고) + 배면 · 기초 물성 +→ 하중 · 안전율 · 판정. 기준 = KDS 11 80 05(지식DB `구조물_안정계산.md` §2 · `original/안정성평가/04`). + +- 토압 = Coulomb(벽 배면 = 뒷굽 바닥 ~ 배면 꼭대기 가상면 · 벽마찰 δ) 또는 Rankine(뒷굽 연직 가상면 · + δ = β) · 지진 시 = Mononobe-Okabe(정적 H/3 + 증분 0.6H · Seed-Whitman) + 벽 관성 kh·W. +- 가상면과 벽 사이 흙 = 벽 무게에 더함 · 배면 점착 = 0(보수 쪽). +- 상재하중 q = 배면 등분포(노면 하중) · 수압 = 배면 수위 hw(바닥부터) 정수압 + 바닥 삼각 양압력 · + 수위 아래 토압 = 수중 단위중량. +- 활동 = (V·μ + c_b·B) / ΣH(수동토압 빼고) · 전도 = 앞굽 기준 저항 / 전도 모멘트 · + 지지력 = Meyerhof 극한(유효폭 B' · 경사 계수) / 최대 지반반력(사다리꼴 · 삼각). +""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np +from shapely.geometry import LineString, Polygon, box +from shapely.ops import unary_union + +GAMMA_W = 9.81 +#: KDS 11 80 05 기준안전율 — 평상시 · 지진 시(활동은 수동토압 뺀 값) +REQUIRED = { + "normal": {"sliding": 1.5, "overturning": 2.0, "bearing": 3.0}, + "seismic": {"sliding": 1.2, "overturning": 1.5, "bearing": 2.0}, +} +#: 벽 단위중량(kN/m³) — 콘크리트 · 찰쌓기 23 · 메쌓기(공극) 20 +WALL_GAMMA = {"default": 23.0, "dry_masonry": 20.0} +_N = 400 # 토압 적분 칸 + + +def ka_coulomb(phi: float, delta: float, theta: float, beta: float, psi: float = 0.0) -> float: + """Coulomb 주동토압계수(ψ > 0 = Mononobe-Okabe KAE · kv = 0). 각 = 도. + + θ = 배면이 연직에서 앞쪽(벽 쪽)으로 누운 각(쐐기가 커지는 쪽 +) · β = 배면 흙 경사 · δ = 벽마찰.""" + f, d, t, b, p = (math.radians(v) for v in (phi, delta, theta, beta, psi)) + under = math.sin(f + d) * math.sin(f - b - p) / (math.cos(d + t + p) * math.cos(b - t)) + root = math.sqrt(max(under, 0.0)) + return math.cos(f - t - p) ** 2 / ( + math.cos(p) * math.cos(t) ** 2 * math.cos(d + t + p) * (1.0 + root) ** 2 + ) + + +def ka_rankine(phi: float, beta: float = 0.0) -> float: + """Rankine 주동토압계수(경사 배면 β · 합력은 β 로 기욺).""" + f, b = math.radians(phi), math.radians(beta) + root = math.sqrt(max(math.cos(b) ** 2 - math.cos(f) ** 2, 0.0)) + return math.cos(b) * (math.cos(b) - root) / (math.cos(b) + root) + + +def bearing_factors(phi: float) -> tuple[float, float, float]: + """Meyerhof 지지력 계수(Nc · Nq · Nγ) — φ 도.""" + f = math.radians(phi) + if f < 1e-9: + return 5.14, 1.0, 0.0 + nq = math.exp(math.pi * math.tan(f)) * math.tan(math.pi / 4 + f / 2) ** 2 + return (nq - 1.0) / math.tan(f), nq, (nq - 1.0) * math.tan(1.4 * f) + + +def _pressure( + ka: float, H: float, gamma: float, gamma_sat: float, q: float, hw: float +) -> tuple[float, float]: + """연직 높이 H 가상면 토압 합력 · 바닥에서 작용 높이 — σ' = 수위 위 γ · 아래 γ' 누적 + q.""" + z = np.linspace(0.0, H, _N + 1) # 꼭대기에서 아래로 + dry = np.minimum(z, H - hw) + wet = np.maximum(z - (H - hw), 0.0) + p = ka * (gamma * dry + (gamma_sat - GAMMA_W) * wet + q) + total = float(np.trapz(p, z)) + arm = float(np.trapz(p * (H - z), z)) / total if total > 0 else H / 3 + return total, arm + + +def check_wall( + polygon: list, + back: int, + *, + backfill: dict[str, float], + foundation: dict[str, float], + method: str = "coulomb", + delta_ratio: float = 2.0 / 3.0, + beta_deg: float = 0.0, + surcharge_kpa: float = 10.0, + water_ratio: float = 0.0, + kh: float = 0.0, + wall_gamma: float = WALL_GAMMA["default"], + base_friction: float | None = None, + base_adhesion: float = 0.0, + embed_m: float = 0.0, +) -> dict[str, Any]: + """벽 하나 외적 안정. back = 배면(흙) 쪽 x 방향(+1 · −1). 물성 = {gamma_t, gamma_sat, c, phi}. + + kh > 0 이면 지진 시(기준 `REQUIRED["seismic"]`) · 아니면 평상시. 돌려줌 = 하중 · 안전율 · 판정.""" + wall = Polygon(polygon) + xs = np.array([p[0] for p in polygon], dtype=float) + zs = np.array([p[1] for p in polygon], dtype=float) + zb, zt = float(zs.min()), float(zs.max()) + H = zt - zb + base = xs[zs <= zb + 1e-3] + heel = float(base.max() if back > 0 else base.min()) + toe = float(base.min() if back > 0 else base.max()) + B = abs(heel - toe) + phi, gamma = float(backfill["phi"]), float(backfill["gamma_t"]) + gamma_sat = float(backfill.get("gamma_sat") or gamma) + hw = min(max(water_ratio, 0.0), 1.0) * H + + if method == "rankine": + x_top, theta, delta = heel, 0.0, beta_deg + else: + top = xs[zs >= zt - 1e-3] + x_top = float(top.max() if back > 0 else top.min()) # 배면 꼭대기 모서리 + theta = math.degrees(math.atan2(-(x_top - heel) * back, H)) + delta = delta_ratio * phi + # 가상면과 벽 사이 흙 — 벽 쪽 반평면 ∩ 벽 상자 − 벽 · 가상면에 붙은 조각만(앞면 앞 흙 빼고) + reach = 10.0 * (H + B + 1.0) + side = [(heel, zb), (x_top, zt), (x_top - back * reach, zt), (heel - back * reach, zb)] + plane = LineString([(heel, zb), (x_top, zt)]) + rest = Polygon(side).intersection(box(*wall.bounds)).difference(wall) + parts = [g for g in getattr(rest, "geoms", [rest]) if isinstance(g, Polygon)] + soil = unary_union([g for g in parts if g.area > 1e-9 and g.distance(plane) < 1e-6]) + soil_w = soil.area * gamma + soil_x = soil.centroid.x if soil_w > 0 else heel + soil_z = soil.centroid.y if soil_w > 0 else zb + + ka = ( + ka_rankine(phi, beta_deg) + if method == "rankine" + else ka_coulomb(phi, delta, theta, beta_deg) + ) + q_eff = surcharge_kpa * ( + 1.0 + if method == "rankine" + else math.cos(math.radians(theta)) / math.cos(math.radians(theta - beta_deg)) + ) + Pa, ya = _pressure(ka, H, gamma, gamma_sat, q_eff, hw) + loads = [("earth", Pa, ya)] + kae = None + if kh > 0: + psi = math.degrees(math.atan(kh)) + kae = ka_coulomb(phi, delta, theta, beta_deg, psi) + Pae, _ = _pressure(kae, H, gamma, gamma_sat, q_eff, hw) + loads.append(("seismic_earth", max(Pae - Pa, 0.0), 0.6 * H)) + incline = math.radians(delta + theta) + + def plane_x(y: float) -> float: # 가상면 위 높이 y 자리 x + return heel + (x_top - heel) * (y / H if H > 0 else 0.0) + + def arm(x: float) -> float: # 앞굽에서 배면 쪽 수평 팔 길이 + return (x - toe) * back + + W = wall.area * wall_gamma + xw, zw = wall.centroid.x, wall.centroid.y + Ph = sum(P for _, P, _ in loads) * math.cos(incline) + Pv = sum(P for _, P, _ in loads) * math.sin(incline) + m_res = W * arm(xw) + soil_w * arm(soil_x) + m_over = 0.0 + for _, P, y in loads: + m_res += P * math.sin(incline) * arm(plane_x(y)) + m_over += P * math.cos(incline) * y + Pw = 0.5 * GAMMA_W * hw**2 + U = 0.5 * GAMMA_W * hw * B + m_over += Pw * hw / 3.0 + U * (2.0 * B / 3.0) + inertia = kh * (W + soil_w) + m_over += kh * W * (zw - zb) + kh * soil_w * (soil_z - zb) + H_total = Ph + Pw + inertia + V = W + soil_w + Pv - U + + phi_f = float(foundation["phi"]) + mu = base_friction if base_friction is not None else math.tan(math.radians(2.0 / 3.0 * phi_f)) + sliding = (V * mu + base_adhesion * B) / H_total if H_total > 0 else math.inf + overturning = m_res / m_over if m_over > 0 else math.inf + xbar = (m_res - m_over) / V if V > 0 else -math.inf + e = B / 2.0 - xbar + if xbar <= 0: + q_max, b_eff = math.inf, 0.0 + elif abs(e) <= B / 6.0: + q_max, b_eff = V / B * (1.0 + 6.0 * abs(e) / B), B - 2.0 * abs(e) + else: + q_max, b_eff = 2.0 * V / (3.0 * min(xbar, B - xbar)), B - 2.0 * abs(e) + nc, nq, ng = bearing_factors(phi_f) + alpha = math.degrees(math.atan2(H_total, V)) if V > 0 else 90.0 + i_cq = (1.0 - alpha / 90.0) ** 2 + i_g = (1.0 - alpha / phi_f) ** 2 if phi_f > 0 and alpha < phi_f else 0.0 + gf = float(foundation["gamma_t"]) + q_ult = ( + float(foundation.get("c") or 0.0) * nc * i_cq + + gf * max(embed_m, 0.0) * nq * i_cq + + 0.5 * gf * max(b_eff, 0.0) * ng * i_g + ) + bearing = q_ult / q_max if q_max > 0 and math.isfinite(q_max) else 0.0 + case = "seismic" if kh > 0 else "normal" + fs = {"sliding": sliding, "overturning": overturning, "bearing": bearing} + need = REQUIRED[case] + return { + "case": case, + "method": method, + "geometry": {"H": H, "B": B, "toe_x": toe, "heel_x": heel, "theta_deg": theta}, + "coefficients": {"ka": ka, "kae": kae, "delta_deg": delta, "beta_deg": beta_deg}, + "loads": { + "W": W, + "W_soil": soil_w, + "Pa": Pa, + "Pa_arm": ya, + "Ph": Ph, + "Pv": Pv, + "Pw": Pw, + "U": U, + "inertia": inertia, + "V": V, + "H": H_total, + "M_resist": m_res, + "M_overturn": m_over, + }, + "base": {"e": e, "q_max": q_max, "q_ult": q_ult, "B_eff": b_eff, "mu": mu, "alpha": alpha}, + "fs": fs, + "required": dict(need), + "ok": {k: bool(fs[k] >= need[k]) for k in fs}, + } diff --git a/E03_StabSection/E03_StabSection_Walls.py b/E03_StabSection/E03_StabSection_Walls.py new file mode 100644 index 000000000..6266b5cf8 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Walls.py @@ -0,0 +1,114 @@ +"""E03 2D 모형의 벽(옹벽 · 석축 · 돌쌓기 · 관 기슭막이) — 복사본 → 측점별 walls +(532-4 · 분석 345-D ② ⑤). + +도형은 짓지 않음 — B06 화면 TS 를 서버 Node 로 돌림(`E03_StabSection_Walls_Node.ts` · 5장 ②). +얹기(어느 측점에 어느 벽) = B06 `attach_saved_shapes` 그대로(복사본 = 원본 프로젝트 배치). +벽 재료 = 모형 `WALL_MATERIAL`(고강도 — 활동면이 벽을 못 지남) · 꼴 = `build_section_model(walls)`. +벽이 선 측점은 지표도 B06 면적과 같은 「그려지는 설계선」(벽 상단에서 끊김)으로 바꿔 넘김. + +부르는 법(E03 분석 길): + found = walls_by_station(root) # 복사본 루트 → {chainage_key: entry} + entry = found.get(chainage_key(cross["chainage_m"])) + model = section_model_from_b06(cross, design_with_walls(design, entry), walls=walls_of(entry)) +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +from common_util.common_util_node_bundle import ROOT, build_bundle, bundle_stale, run_bundle_json +from common_util.common_util_station_key import chainage_key +from E01_StabRegister import E01_StabRegister_Source as source +from E03_StabSection.E03_StabSection_Engine_Model import WALL_MATERIAL + +logger = logging.getLogger(__name__) + +NPM_SCRIPT = "build:e03-walls" +ENTRY = ROOT / "E03_StabSection" / "E03_StabSection_Walls_Node.ts" +BUNDLE = ROOT / "config" / "e03_walls_node" / "E03_StabSection_Walls_Node.js" + + +def _sections(root: Path) -> list[dict[str, Any]]: + """복사본 측점 → B06 상세 꼴(chainage_m · 중심 · samples · design) + + 형상 얹기(revetment · culvert …).""" + from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes + + sections = [] + for station in source.stations(root): + cross = source.cross_section(root, station["chainage_m"]) + sections.append( + { + **cross, + "center_x": station.get("center_x"), + "center_y": station.get("center_y"), + } + ) + route = source.db_snapshot(root).get("route") or {} + attach_saved_shapes(root, sections, route.get("route_data_path")) + return sections + + +def _fresh_bundle() -> bool: + """입구 TS 는 `SOURCE_DIRS` 밖(E03) — 입구가 묶음보다 새로우면 여기서 다시 지음.""" + stale = bundle_stale(BUNDLE) or ENTRY.stat().st_mtime > BUNDLE.stat().st_mtime + return not stale or build_bundle(NPM_SCRIPT) + + +def walls_by_station(root: Path | str) -> dict[str, dict[str, Any]]: + """복사본 루트 → {chainage_key: {walls: [모형 벽 …], design_line: [[offset, z] …] | None}}. + + 벽 · 트림이 없는 측점은 안 실음(그 측점 모형 = 종전 그대로). Node 실패는 던짐 — 벽을 빼고 + 해석하면 활동면이 벽을 지난 값이 조용히 남음. + """ + return walls_for_sections(_sections(Path(root))) + + +def walls_for_sections(sections: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + """형상 얹은 B06 횡단 목록 → 측점 entry(`walls_by_station` 몸 · 시험이 바로 부름).""" + from B05_Profile.B05_Profile_Structures_Schema import load_definition + + if not any(s.get("revetment") or s.get("culvert") for s in sections): + return {} + if not _fresh_bundle(): + raise RuntimeError("E03 벽 도형 번들을 못 지음") + payload = json.loads( + json.dumps({"definition": load_definition(), "sections": sections}, default=str) + ) + out = run_bundle_json(BUNDLE, NPM_SCRIPT, payload) + if not isinstance(out, dict): + raise RuntimeError("E03 벽 도형 Node 실패") + found: dict[str, dict[str, Any]] = {} + for row in out["stations"]: + walls = [ + { + "material_id": WALL_MATERIAL["id"], + "kind": w["kind"], + "name": w.get("name"), + "structure_id": w.get("structure_id"), + "form": w.get("form"), + "polygon": w["polygon"], + } + for w in row["walls"] + ] + if walls or row.get("design_line"): + found[chainage_key(float(row["chainage_m"]))] = { + "walls": walls, + "design_line": row.get("design_line"), + } + return found + + +def walls_of(entry: dict[str, Any] | None) -> list[dict[str, Any]] | None: + """측점 entry → `section_model_from_b06(walls=…)` 값(없으면 None = 벽 없음).""" + return (entry or {}).get("walls") or None + + +def design_with_walls(design: dict[str, Any], entry: dict[str, Any] | None) -> dict[str, Any]: + """벽이 선 측점 = 설계선을 그려지는 선(벽 상단에서 끊김)으로 바꾼 사본 · 아니면 그대로.""" + line = (entry or {}).get("design_line") + if not line: + return design + return {**design, "design_line": [{"offset_m": o, "elevation_m": z} for o, z in line]} diff --git a/E03_StabSection/E03_StabSection_Walls_Node.ts b/E03_StabSection/E03_StabSection_Walls_Node.ts new file mode 100644 index 000000000..8eb239fc9 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Walls_Node.ts @@ -0,0 +1,102 @@ +/* ============================================================================= + * E03_StabSection_Walls_Node.ts + * E 2D 해석 모형의 벽(옹벽 · 석축 · 돌쌓기 · 관 기슭막이) 단면 꼭짓점을 **서버가** 내는 입구(532-4). + * + * 도형은 B06 화면이 그리는 TS 그대로(`computeStoredLayouts` — 정본만 읽는 한 벌 · CLAUDE.md 5장 ②) · + * 지표 = B06 면적과 같은 「실제로 그려지는 설계선」(`drawnProfile` · 벽 상단에서 끊김). 여기에 계산 없음. + * 파이썬 껍데기 = `E03_StabSection_Walls.py` · 번들 = `npm run build:e03-walls`. + * + * 실행: node <번들> <입력.json> <출력.json> + * 입력 { definition?: 구조물 정의 · sections: 형상 얹은 B06 횡단(chainage_m · samples · design · revetment …) } + * 출력 { stations: [{ chainage_m, walls: [{ kind, name, structure_id, form, polygon: [[offset, z], …] }], + * design_line: [[offset, z], …] | null(트림 없음 = 설계선 그대로) }] } — 입력 차례 그대로 + * 끝 코드: 0 성공 / 2 인자 오류 + * ========================================================================== */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { drawnProfile } from "@util/common_util_cross_structure_areas"; +import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import type { WallLayout } from "../B06_Section/logic/B06_Section_Cross_Culvert_Types"; +import { + computeStoredLayouts, + trimOfLayouts, +} from "../B06_Section/logic/B06_Section_Structure_Layouts"; + +/** 트림 경계 바로 바깥 점 — 벽 쪽 연직 단차를 살림(`computeStructureAreas` 와 같은 값). */ +const BOUNDARY_EPS_M = 1e-6; + +type Pair = [number, number]; + +function stationWalls(section: CrossSection, sections: readonly CrossSection[]) { + const layouts = computeStoredLayouts(section, sections); + const walls: Array<{ + kind: string; + name: string | null; + structure_id: string | null; + form: string | null; + polygon: Pair[]; + }> = []; + const push = (kind: string, list: readonly WallLayout[] | undefined, name: string | null) => { + for (const wall of list ?? []) { + if (wall.points.length >= 3) + walls.push({ + kind, + name, + structure_id: kind === "own" ? (layouts?.own?.structureId ?? null) : null, + form: wall.form, + polygon: wall.points.map((p) => [p.offset, p.elevation]), + }); + } + }; + push("own", layouts?.own?.walls, layouts?.own?.name ?? null); + push("culvert", layouts?.culvert?.walls, "기슭막이"); + push("culvert", layouts?.culvert?.basinExtras, "기슭막이"); + const trim = layouts ? trimOfLayouts(layouts) : null; + const line = layouts?.design.design_line; + let design_line: Pair[] | null = null; + if (trim && Array.isArray(line) && line.length >= 2) { + const ground = section.samples + .filter((s) => s.valid !== false && typeof s.elevation_m === "number") + .map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const profile = drawnProfile({ + designLine: line as Array<{ offset_m: number; elevation_m: number }>, + ground, + trim, + }); + if (profile && ground.length >= 2) { + const lo = ground[0].offset; + const hi = ground[ground.length - 1].offset; + const grid = new Set(); + const add = (o: number) => { + if (o >= lo && o <= hi) grid.add(Math.round(o * 1e6) / 1e6); + }; + ground.forEach((g) => add(g.offset)); + line.forEach((p) => add(p.offset_m)); + for (const b of [trim.minOffset, trim.maxOffset]) + [b, b - BOUNDARY_EPS_M, b + BOUNDARY_EPS_M].forEach(add); + trim.minSlope?.points.forEach((p) => add(p.offset)); + trim.maxSlope?.points.forEach((p) => add(p.offset)); + design_line = [...grid].sort((a, b) => a - b).map((o) => [o, profile.drawnZ(o)]); + } + } + return { chainage_m: section.chainage_m, walls, design_line }; +} + +const [inputPath, outputPath] = process.argv.slice(2); +if (!inputPath || !outputPath) { + console.error("사용법: node <번들> <입력.json> <출력.json>"); + process.exit(2); +} +const input = JSON.parse(readFileSync(inputPath, "utf8")) as { + definition?: CatalogDefinition | null; + sections: CrossSection[]; +}; +if (input.definition) useCatalogDefinition(input.definition); +const sections = input.sections ?? []; +writeFileSync( + outputPath, + JSON.stringify({ stations: sections.map((s) => stationWalls(s, sections)) }), + "utf8", +); diff --git a/E03_StabSection/E03_StabSection_WhatIf.py b/E03_StabSection/E03_StabSection_WhatIf.py new file mode 100644 index 000000000..9593dbf87 --- /dev/null +++ b/E03_StabSection/E03_StabSection_WhatIf.py @@ -0,0 +1,199 @@ +"""E03 보강 가정 — 비탈 기울기 완화 · 소단 덧붙임 → 설계선 다시 그림 · 최소 완화 찾기 (PLAN 536-3). + +storage 를 모르는 순수 함수. 가정 = 모형 형상만 바꿈(설계 · 임도 쪽으로 안 돌려 씀 · 요구 345 E). +- `reshape_side` = 한 쪽 비탈을 비탈 시작점에서 1:n(+ 높이 h 마다 폭 w 소단)으로 원지반까지 다시 그림 · + 안쪽(노면 · 측구)은 그대로 · 2단(암 · 토사) 경사는 한 경사로 갈음. +- `slope_start` = 비탈 시작점 — 깎기 = 측구 바깥(`cut_slope_segments` 시작) · 쌓기 = 노면 끝. +- `search_ratio` = 목표 안전율을 맞추는 가장 급한 1:n(이분 · `step` 단위 올림) — 해석은 부르는 쪽 함수. +좌표 = B06 offset(좌 +) · 표고. +""" + +from __future__ import annotations + +import math +from typing import Any, Callable + +import numpy as np + +#: 최소 완화 찾기 — 가장 완만한 기울기 · 찾는 단위 +MAX_RATIO = 4.0 +RATIO_STEP = 0.05 + + +def current_ratio(design: dict[str, Any], side: str, role: str) -> float: + """지금 설계 기울기 1:n 의 n — 깎기 = 그 쪽 첫 구간 · 쌓기 = 쌓기 기울기.""" + if role == "fill": + return float(design.get("fill_slope_ratio") or 1.5) + segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side] + if segs: + return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["ratio"]) + return float(design.get("cut_slope_ratio") or 1.0) + + +def slope_start(design: dict[str, Any], side: str, role: str) -> float: + """비탈 시작 offset.""" + edge = float(((design.get("road_edges") or {}).get(side) or {}).get("offset_m", 0.0)) + if role == "cut": + segs = [s for s in design.get("cut_slope_segments") or [] if s.get("side") == side] + if segs: + return float(min(segs, key=lambda s: abs(s["start_offset_m"]))["start_offset_m"]) + return edge + + +def _profile( + x0: float, z0: float, sign: float, up: float, ratio: float, bench_w: float, bench_h: float +) -> list[tuple[float, float]]: + """비탈 꼭짓점(시작 → 바깥) — 원지반을 넉넉히 지나도록 길게(뒤에서 자름).""" + pts = [(x0, z0)] + x, z = x0, z0 + while abs(x - x0) < 200.0: + rise = bench_h if bench_w > 0 and bench_h > 0 else 200.0 + x, z = x + sign * rise * ratio, z + up * rise + pts.append((x, z)) + if bench_w > 0 and bench_h > 0: + x += sign * bench_w + pts.append((x, z)) + return pts + + +def _daylight( + pts: list[tuple[float, float]], ground: list, sign: float, up: float +) -> list[tuple[float, float]] | None: + """비탈이 원지반과 처음 만나는 점까지 — 못 만나면 None(지형 밖). 시작부터 원지반 밖이면 시작점만.""" + gx = np.array([p[0] for p in ground], dtype=float) + gz = np.array([p[1] for p in ground], dtype=float) + out = [pts[0]] + for (xa, za), (xb, zb) in zip(pts, pts[1:]): + inside = gx[(gx > min(xa, xb)) & (gx < max(xa, xb))] + xs = np.unique(np.r_[xa, xb, inside]) + xs = xs[np.argsort(sign * xs)] + zl = za + (xs - xa) / (xb - xa) * (zb - za) if xb != xa else np.full(len(xs), zb) + f = up * (zl - np.interp(xs, gx, gz)) # ≥ 0 = 원지반 밖으로 나옴 + if len(out) == 1 and f[0] >= 0: + return out + for k in range(1, len(xs)): + if not gx[0] <= xs[k] <= gx[-1]: + return None + if f[k] >= 0: + s = f[k - 1] / (f[k - 1] - f[k]) + x = float(xs[k - 1] + s * (xs[k] - xs[k - 1])) + return out + [(x, float(zl[k - 1] + s * (zl[k] - zl[k - 1])))] + out.append((xb, zb)) + return None + + +def reshape_side( + design_line: list, + ground: list, + *, + side: str, + role: str, + start_x: float, + ratio: float, + bench_w: float = 0.0, + bench_h: float = 0.0, +) -> list[list[float]] | None: + """한 쪽 비탈만 다시 그린 설계선([[offset, z] …]) — 원지반을 못 만나면 None.""" + line = sorted((float(x), float(z)) for x, z in design_line) + sign = 1.0 if side == "left" else -1.0 + up = 1.0 if role == "cut" else -1.0 + lx = np.array([p[0] for p in line]) + lz = np.array([p[1] for p in line]) + z0 = float(np.interp(start_x, lx, lz)) + inner = [p for p in line if sign * p[0] < sign * start_x - 1e-9] + slope = _daylight(_profile(start_x, z0, sign, up, ratio, bench_w, bench_h), ground, sign, up) + if slope is None: + return None + return [list(p) for p in sorted(inner + slope)] + + +def design_variant( + design: dict[str, Any], ground: list, assume: dict[str, Any] +) -> dict[str, Any] | None: + """가정 하나(side · role · ratio · bench_w · bench_h) → 설계선만 바꾼 설계 사본(None = 원지반 못 만남).""" + side, role = assume["side"], assume["role"] + pts = [(p["offset_m"], p["elevation_m"]) for p in design.get("design_line") or []] + line = reshape_side( + pts, + ground, + side=side, + role=role, + start_x=slope_start(design, side, role), + ratio=float(assume["ratio"]), + bench_w=float(assume.get("bench_w") or 0.0), + bench_h=float(assume.get("bench_h") or 0.0), + ) + if line is None: + return None + return {**design, "design_line": [{"offset_m": x, "elevation_m": z} for x, z in line]} + + +def gentlest_ratio( + design: dict[str, Any], + ground: list, + assume: dict[str, Any], + start: float, + step: float = RATIO_STEP, +) -> float | None: + """원지반을 만나는 가장 완만한 n(start ~ MAX_RATIO · step 단위 · 형상만 · 이분) — 없으면 None.""" + + def meets(n: float) -> bool: + return design_variant(design, ground, {**assume, "ratio": n}) is not None + + lo = math.ceil(start / step - 1e-9) * step + hi = math.floor(MAX_RATIO / step + 1e-9) * step + if not meets(lo): + return None + if meets(hi): + return round(hi, 4) + while hi - lo > step + 1e-9: # lo = 만남 · hi = 못 만남 + mid = round(round((lo + hi) / 2 / step) * step, 6) + if mid in (lo, hi): + break + lo, hi = (mid, hi) if meets(mid) else (lo, mid) + return round(lo, 4) + + +def search_ratio( + passes: Callable[[float], bool], + start: float, + *, + max_ratio: float = MAX_RATIO, + step: float = RATIO_STEP, + coarse: int = 5, +) -> dict[str, Any]: + """목표를 맞추는 가장 급한 n(start ≤ n ≤ max_ratio · step 단위). passes(n) = 해석 · 판정. + + 완만할수록 늘 안전하지 않음(급한 자연 비탈 = 완화가 깎기 높이를 키움) — 성긴 걸음(최대 `coarse` 칸)으로 + 처음 맞는 칸을 찾고 그 앞 칸 사이를 이분으로 좁힘 · 끝까지 못 맞추면 found None.""" + tried: list[tuple[float, bool]] = [] + + def test(n: float) -> bool: + ok = passes(n) + tried.append((round(n, 4), ok)) + return ok + + def snap(n: float) -> float: + return round(round(n / step) * step, 6) + + lo = snap(math.ceil(start / step - 1e-9) * step) + top = snap(math.floor(max_ratio / step + 1e-9) * step) + if test(lo): + return {"found": lo, "tried": tried} + gap = max(2 * step, snap((top - lo) / coarse)) + hi = None + n = lo + while n < top - 1e-9: + n = min(snap(n + gap), top) + if test(n): + hi = n + break + lo = n + if hi is None: + return {"found": None, "tried": tried} + while hi - lo > step + 1e-9: # lo = 못 맞춤 · hi = 맞춤 + mid = snap((lo + hi) / 2) + if mid in (lo, hi): + break + lo, hi = (lo, mid) if test(mid) else (mid, hi) + return {"found": round(hi, 4), "tried": tried} diff --git a/E03_StabSection/E03_StabSection_Worker.py b/E03_StabSection/E03_StabSection_Worker.py new file mode 100644 index 000000000..e69e100f4 --- /dev/null +++ b/E03_StabSection/E03_StabSection_Worker.py @@ -0,0 +1,84 @@ +"""E03 측점 해석 일꾼 — 서버 밖 프로세스(PLAN 532-3). + +`python -m E03_StabSection.E03_StabSection_Worker ` — 서버(`Router.run_all`)가 띄움. +측점마다 프로세스로 나눠 돌리고 끝날 때마다 파생 저장본을 쓰고 한 줄(JSON)을 찍음 → 서버 진행률. +서버 프로세스에서 바로 프로세스 풀을 열면 자식마다 `main.py` 를 다시 읽어 이 모듈을 입구로 둠. +job = {root, keep, kwargs, workers, conditions_ground, items: [{fp, meta, layers, cross, design, +walls, ground}]} · conditions_ground = E02 지반 조건(지하수 갈래 → 측점 수위). +""" + +from __future__ import annotations + +import json +import logging +import sys +from concurrent.futures import ProcessPoolExecutor, as_completed +from functools import partial +from pathlib import Path +from typing import Any, Callable + +from common_util.common_util_derived_store import write_derived +from E02_StabGround.E02_StabGround_Model import station_groundwater +from E03_StabSection.E03_StabSection_Engine_Station import analyze_station + +KIND_STATION = "stab2d_station" +logger = logging.getLogger(__name__) + + +def _analyze( + item: dict[str, Any], kwargs: dict[str, Any], conditions_ground: dict | None = None +) -> dict[str, Any]: + kw = {**kwargs, "layers": item["layers"], "ground": [tuple(p) for p in item["ground"]]} + if item.get("walls"): + kw["walls"] = item["walls"] + if conditions_ground is not None: + kw["groundwater_for"] = partial(station_groundwater, conditions_ground) + return analyze_station(item["cross"], item["design"], **kw) + + +def _value(item: dict[str, Any], out: dict | None, error: str | None) -> dict[str, Any]: + return { + "meta": item["meta"], + "inputs": {"layers": item["layers"], "ground": item["ground"]}, + "out": out, + "error": error, + } + + +def run_job(job: dict[str, Any], emit: Callable[[str], None]) -> None: + """측점마다 해석 → 저장본 · emit(열쇠). 한 측점 실패는 그 줄에 까닭만 남기고 이어 감.""" + root, keep, kwargs = Path(job["root"]), int(job["keep"]), job["kwargs"] + conditions_ground = job.get("conditions_ground") + items = job["items"] + + def save(item: dict[str, Any], out: dict | None, error: str | None) -> None: + write_derived(root, KIND_STATION, item["fp"], _value(item, out, error), keep=keep) + emit(item["meta"]["key"]) + + workers = max(1, min(int(job.get("workers") or 1), len(items))) + if workers == 1: + for item in items: + try: + save(item, _analyze(item, kwargs, conditions_ground), None) + except Exception as error: # noqa: BLE001 + logger.exception("E03 측점 해석 실패: %s", item["meta"]["key"]) + save(item, None, str(error)) + return + with ProcessPoolExecutor(max_workers=workers) as pool: + futures = {pool.submit(_analyze, item, kwargs, conditions_ground): item for item in items} + for future in as_completed(futures): + item = futures[future] + try: + save(item, future.result(), None) + except Exception as error: # noqa: BLE001 + logger.exception("E03 측점 해석 실패: %s", item["meta"]["key"]) + save(item, None, str(error)) + + +def main() -> None: + job = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + run_job(job, lambda key: print(json.dumps({"done": key}), flush=True)) + + +if __name__ == "__main__": + main() diff --git a/E03_StabSection/draft/E03_StabSection_Route.ts b/E03_StabSection/draft/E03_StabSection_Route.ts new file mode 100644 index 000000000..e94e8ae84 --- /dev/null +++ b/E03_StabSection/draft/E03_StabSection_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E03_StabSection_Route.ts + * 길 선언(PLAN 529-1 · 532-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E03 2D 측점 해석 페이지 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E03_STAB_SECTION, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E03_StabSection_UI_Page")).renderE03StabSection, +}; diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts new file mode 100644 index 000000000..c869cb37a --- /dev/null +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -0,0 +1,414 @@ +/* ============================================================================= + * E03_StabSection_Logic.ts + * E03 2D 측점 해석 화면 계산(PLAN 532-3) — 화면 · three · fetch · sessionStorage 없음 · 순수 함수만. + * 좌표 = 서버 결과 그대로(x = B06 offset · 좌 + · z = 표고). 화면은 B06 처럼 좌측(+offset)을 왼쪽에 둠 + * → 화면 x = −k·x + tx · y = −k·z + ty (`fitView`). SVG 도형 좌표는 결과 좌표를 그대로 쓰고 + * 묶음 transform 하나(`viewMatrix`)로 옮김 — 「SVG 좌표 = 결과 좌표」. + * 판정 = 서버 `E03_StabSection_View.overall_verdict` 와 같은 규칙(ng > ok > none). + * ========================================================================== */ + +export type Condition = "dry" | "wet" | "seismic"; +export type Verdict = "ok" | "ng" | "none"; +export type Point = [number, number]; + +export interface SideCase { + fs: number | null; + required: number | null; + ok: boolean | null; + kind: string | null; + converged: boolean | null; +} + +export interface StationRow { + key: string; + chainage_m: number; + label: string | null; + section_mode: string | null; + ground_type: string | null; + center: [number | null, number | null] | null; + sides: { side: string; role: "cut" | "fill"; height_m: number | null }[]; + cases: Partial>>; + min: Partial>; + verdict: Partial>; + error: string | null; + /** 그 측점 벽(옹벽 · 석축) 수 — 옛 저장본은 없음 */ + walls?: number; + representative?: { auto: string[]; user: boolean | null; on: boolean }; +} + +export interface Surface { + method: string; + kind: string | null; + fs: number | null; + points: Point[]; + entry: Point | null; + exit: Point | null; + converged: boolean | null; + iterations: number | null; + center?: Point; + radius?: number; +} + +export interface SliceRow { + i: number; + x_left: number; + x_right: number; + width: number; + height: number; + alpha_deg: number; + weight: number; + surcharge: number; + pore_pressure: number; + base_length: number; + c: number; + phi_deg: number; + material: string | null; + N: number; + N_eff: number; + U: number; + S: number; + E_left: number; + E_right: number; + X_left: number; + X_right: number; +} + +export interface Analysis { + condition: Condition; + side: string; + role: "cut" | "fill"; + height_m: number | null; + limits: [number, number]; + direction: number; + required: number | null; + ok: boolean | null; + min_by_method: Record; + /** 「해 없음」 면이 있는 방법(Spencer · M-P) — 옛 저장본은 없음 */ + no_solution?: string[]; + critical: Surface | null; + candidates: Surface[]; + slices: SliceRow[]; + grid: { method: string; x: number[]; z: number[]; fs: (number | null)[][] } | null; +} + +export interface SectionView { + key: string; + chainage_m: number; + label: string | null; + materials: { + id: string; + name: string; + gamma_t: number; + gamma_sat: number; + c: number; + phi: number; + }[]; + regions: { material_id: string; polygon: Point[]; holes: Point[][] }[]; + surface: Point[]; + bottom_z: number | null; + surcharge: { x0: number; x1: number; q: number }[]; + water: Partial>; + /** 조건별 지하수 꼴(E02 갈래) — r_u 면 수위선 없음 */ + water_rule?: Partial>; + analyses: Analysis[]; +} + +/* ---- 판정 · 목록 ---------------------------------------------------------- */ + +/** 측점 판정 — 조건 하나(또는 전 조건 중 가장 나쁜 것) */ +export function verdictOf(row: StationRow, condition?: Condition): Verdict { + const values = condition ? [row.verdict[condition] ?? "none"] : Object.values(row.verdict); + if (values.includes("ng")) return "ng"; + return values.includes("ok") ? "ok" : "none"; +} + +export type SortMode = "chainage" | "fs"; + +/** 목록 정렬 · 거름 — fs = 그 조건 최소 안전율 낮은 순(없으면 맨 뒤) */ +export function listRows( + rows: StationRow[], + condition: Condition, + sort: SortMode, + ngOnly: boolean, +): StationRow[] { + const kept = ngOnly ? rows.filter((r) => verdictOf(r, condition) === "ng") : rows.slice(); + if (sort === "fs") { + const fs = (r: StationRow): number => r.min[condition] ?? Number.POSITIVE_INFINITY; + kept.sort((a, b) => fs(a) - fs(b) || a.chainage_m - b.chainage_m); + } else kept.sort((a, b) => a.chainage_m - b.chainage_m); + return kept; +} + +/** 깎기 · 쌓기 줄임 — 비탈 역할 좌/우(예 「쌓기 · 깎기」) */ +export function roleText(row: StationRow, names: { cut: string; fill: string }): string { + const order = ["left", "right"]; + return row.sides + .slice() + .sort((a, b) => order.indexOf(a.side) - order.indexOf(b.side)) + .map((s) => names[s.role]) + .join(" · "); +} + +/** 평면 띠 — 측점 중심을 이은 선을 측점 판정으로 나눈 구간(이웃 측점 사이 중간에서 끊음) */ +export function bandSegments( + rows: StationRow[], + condition: Condition | undefined, +): { verdict: Verdict; points: Point[]; keys: string[] }[] { + const pts = rows + .filter((r) => r.center && r.center[0] !== null && r.center[1] !== null) + .sort((a, b) => a.chainage_m - b.chainage_m) + .map((r) => ({ row: r, p: [r.center![0]!, r.center![1]!] as Point })); + const out: { verdict: Verdict; points: Point[]; keys: string[] }[] = []; + const mid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + pts.forEach((item, i) => { + const v = verdictOf(item.row, condition); + const start = i > 0 ? mid(pts[i - 1].p, item.p) : item.p; + const end = i < pts.length - 1 ? mid(item.p, pts[i + 1].p) : item.p; + const last = out[out.length - 1]; + if (last && last.verdict === v) { + last.points.push(item.p, end); + last.keys.push(item.row.key); + } else out.push({ verdict: v, points: [start, item.p, end], keys: [item.row.key] }); + }); + return out; +} + +/** 평면 띠 보기 틀 — 북쪽 위 · 같은 축척 · 여백 pad(px) */ +export function planFit( + points: Point[], + width: number, + height: number, + pad: number, +): { k: number; tx: number; ty: number } { + const xs = points.map((p) => p[0]); + const ys = points.map((p) => p[1]); + const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]; + const k = Math.min( + (width - 2 * pad) / Math.max(x1 - x0, 1), + (height - 2 * pad) / Math.max(y1 - y0, 1), + ); + return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 }; +} + +/* ---- 안전율 색 ------------------------------------------------------------ */ + +/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */ +export const RATIO_BREAKS = [0.8, 0.9, 1.0, 1.1, 1.25, 1.5] as const; + +/** 띠 번호 0~6 — 0 = 가장 위험 · 3 = 기준 바로 위 · null = 값 없음 */ +export function bandIndex(fs: number | null | undefined, required: number | null): number | null { + if (fs === null || fs === undefined || !Number.isFinite(fs)) return null; + const ratio = Math.round((fs / (required && required > 0 ? required : 1)) * 1e9) / 1e9; + const i = RATIO_BREAKS.findIndex((b) => ratio < b); + return i < 0 ? RATIO_BREAKS.length : i; +} + +/** 범례 — 띠마다 안전율 구간(기준안전율을 곱한 실제 값) */ +export function bandLegend( + required: number | null, +): { index: number; from: number | null; to: number | null }[] { + const base = required && required > 0 ? required : 1; + const edges = RATIO_BREAKS.map((b) => Math.round(b * base * 100) / 100); + return [...Array(RATIO_BREAKS.length + 1).keys()].map((i) => ({ + index: i, + from: i === 0 ? null : edges[i - 1], + to: i === RATIO_BREAKS.length ? null : edges[i], + })); +} + +/* ---- 횡단 보기 틀 --------------------------------------------------------- */ + +export interface ViewFit { + k: number; // px / m (가로 · 세로 같음 — 참 모양) + tx: number; + ty: number; +} + +/** 범위(지표 · 바닥 · 격자 중심 · 임계면) 를 판에 맞춤 — 좌측(+offset) 이 왼쪽 */ +export function fitView(view: SectionView, width: number, height: number, pad: number): ViewFit { + const xs: number[] = []; + const zs: number[] = []; + const add = (p: Point): void => { + xs.push(p[0]); + zs.push(p[1]); + }; + view.surface.forEach(add); + if (view.bottom_z !== null) zs.push(view.bottom_z); + for (const a of view.analyses) { + if (a.grid) { + a.grid.x.forEach((x) => xs.push(x)); + a.grid.z.forEach((z) => zs.push(z)); + } + a.critical?.points.forEach(add); + } + const [x0, x1, z0, z1] = [Math.min(...xs), Math.max(...xs), Math.min(...zs), Math.max(...zs)]; + const k = Math.min( + (width - 2 * pad) / Math.max(x1 - x0, 1), + (height - 2 * pad) / Math.max(z1 - z0, 1), + ); + return { k, tx: width / 2 + (k * (x0 + x1)) / 2, ty: height / 2 + (k * (z0 + z1)) / 2 }; +} + +/** + * 처음 보기(줌 · 팬) — 그 비탈 탐색 범위 · 임계면 · 격자 중심을 판 가운데 크게(여백 25 %). + * 판 전체(scale 1) = 모형 전체 · 두 번 누름 = 이 보기 · 배율은 1~maxScale. + */ +export function focusHome( + fit: ViewFit, + view: SectionView, + analysis: Analysis | null, + width: number, + height: number, + maxScale: number, +): { scale: number; tx: number; ty: number } { + if (!analysis) return { scale: 1, tx: 0, ty: 0 }; + const pts: Point[] = []; + const [x0, x1] = analysis.limits; + view.surface.filter((p) => p[0] >= x0 && p[0] <= x1).forEach((p) => pts.push(p)); + analysis.critical?.points.forEach((p) => pts.push(p)); + if (analysis.grid) { + const g = analysis.grid; + pts.push([Math.min(...g.x), Math.min(...g.z)], [Math.max(...g.x), Math.max(...g.z)]); + } + if (!pts.length) return { scale: 1, tx: 0, ty: 0 }; + const scr = pts.map((p) => toScreen(fit, p)); + const [sx0, sx1] = [Math.min(...scr.map((p) => p[0])), Math.max(...scr.map((p) => p[0]))]; + const [sy0, sy1] = [Math.min(...scr.map((p) => p[1])), Math.max(...scr.map((p) => p[1]))]; + const raw = Math.min( + width / Math.max((sx1 - sx0) * 1.25, 1), + height / Math.max((sy1 - sy0) * 1.25, 1), + ); + const scale = Math.min(Math.max(raw, 1), maxScale); + return { + scale, + tx: width / 2 - (scale * (sx0 + sx1)) / 2, + ty: height / 2 - (scale * (sy0 + sy1)) / 2, + }; +} + +/** 결과 좌표 → 화면 px */ +export function toScreen(fit: ViewFit, p: Point): Point { + return [-fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; +} + +/** 화면 px → 결과 좌표(마우스 아래 값) */ +export function toModel(fit: ViewFit, p: Point): Point { + return [(fit.tx - p[0]) / fit.k, (fit.ty - p[1]) / fit.k]; +} + +/** 결과 좌표 도형 묶음에 거는 transform — 도형 points 는 결과 좌표 그대로 */ +export function viewMatrix(fit: ViewFit): string { + return `matrix(${-fit.k} 0 0 ${-fit.k} ${fit.tx} ${fit.ty})`; +} + +/** 폴리곤 · 선 → SVG points 글(결과 좌표 그대로) */ +export function pointsText(points: Point[]): string { + return points.map((p) => `${p[0]},${p[1]}`).join(" "); +} + +/** 꺾은선 위 x 의 z(선형 · 범위 밖은 끝값) — 선은 x 오름 · 내림 무관 */ +export function zAt(line: Point[], x: number): number | null { + if (!line.length) return null; + const pts = line.slice().sort((a, b) => a[0] - b[0]); + if (x <= pts[0][0]) return pts[0][1]; + for (let i = 1; i < pts.length; i += 1) { + if (x <= pts[i][0]) { + const [a, b] = [pts[i - 1], pts[i]]; + const t = b[0] === a[0] ? 0 : (x - a[0]) / (b[0] - a[0]); + return a[1] + t * (b[1] - a[1]); + } + } + return pts[pts.length - 1][1]; +} + +/** 절편 네 꼭짓점 — 바닥 = 임계면 · 위 = 지표 */ +export function sliceShape(slice: SliceRow, surface: Point[], base: Point[]): Point[] { + const at = (line: Point[], x: number): number => zAt(line, x) ?? 0; + return [ + [slice.x_left, at(base, slice.x_left)], + [slice.x_right, at(base, slice.x_right)], + [slice.x_right, at(surface, slice.x_right)], + [slice.x_left, at(surface, slice.x_left)], + ]; +} + +/* ---- 자유물체도 --------------------------------------------------------- */ + +export interface ForceArrow { + name: "W" | "Q" | "N" | "U" | "S" | "EL" | "ER" | "XL" | "XR"; + value: number; + /** 화살 시작 · 끝(결과 좌표 · 길이 = 값 × scale) */ + from: Point; + to: Point; +} + +/** + * 절편 하나 힘 화살 — W · Q 아래 · N · U 바닥 법선(절편 안쪽) · S 바닥 따라 미끄럼 반대 · + * E 좌우 면 수평 · X 좌우 면 연직. 엔진 절편표는 활동 방향(direction)으로 뒤집은 틀의 차례라 + * 그 틀의 「왼쪽 면」은 direction < 0 이면 결과 좌표의 오른쪽 면. 길이 = 값 × lengthPerKn. + */ +export function forceArrows( + slice: SliceRow, + shape: Point[], + direction: number, + lengthPerKn: number, +): ForceArrow[] { + const s = direction < 0 ? -1 : 1; + const [bl, br, tr, tl] = shape; + const midBase: Point = [(bl[0] + br[0]) / 2, (bl[1] + br[1]) / 2]; + const midTop: Point = [(tl[0] + tr[0]) / 2, (tl[1] + tr[1]) / 2]; + const centroid: Point = [(midBase[0] + midTop[0]) / 2, (midBase[1] + midTop[1]) / 2]; + const len = Math.hypot(br[0] - bl[0], br[1] - bl[1]) || 1; + const t: Point = [(br[0] - bl[0]) / len, (br[1] - bl[1]) / len]; // 바닥 왼→오 + const n: Point = [-t[1], t[0]]; // 바닥 법선 위쪽 + const faceMid = (a: Point, b: Point): Point => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; + const [nearFace, farFace] = + s > 0 ? [faceMid(bl, tl), faceMid(br, tr)] : [faceMid(br, tr), faceMid(bl, tl)]; + const arrow = ( + name: ForceArrow["name"], + value: number, + at: Point, + dir: Point, + ending = false, + ): ForceArrow => { + const d: Point = [dir[0] * value * lengthPerKn, dir[1] * value * lengthPerKn]; + return ending + ? { name, value, from: [at[0] - d[0], at[1] - d[1]], to: at } + : { name, value, from: at, to: [at[0] + d[0], at[1] + d[1]] }; + }; + return [ + arrow("W", slice.weight, centroid, [0, -1]), + arrow("Q", slice.surcharge, midTop, [0, -1], true), + arrow("N", slice.N_eff, midBase, n, true), + arrow("U", slice.U, midBase, n, true), + arrow("S", slice.S, midBase, [-s * t[0], -s * t[1]], true), + arrow("EL", slice.E_left, nearFace, [s, 0], true), + arrow("ER", slice.E_right, farFace, [-s, 0], true), + arrow("XL", slice.X_left, nearFace, [0, 1], true), + arrow("XR", slice.X_right, farFace, [0, -1], true), + ].filter((a) => Math.abs(a.value) > 1e-9); +} + +/** 절편력 그래프 꼴 — 절편 가운데 x 마다 바닥 유효 수직응력 · 간극수압 · 동원 전단응력(kPa · 한 축) */ +export function sliceStress( + slices: SliceRow[], +): { x: number; sigma: number; u: number; tau: number }[] { + return slices.map((s) => { + const l = s.base_length > 0 ? s.base_length : 1; + return { x: (s.x_left + s.x_right) / 2, sigma: s.N_eff / l, u: s.pore_pressure, tau: s.S / l }; + }); +} + +/** 그래프 눈금 — 0 포함 · 1 · 2 · 5 × 10ⁿ 간격 · 대략 count 칸 */ +export function niceTicks(min: number, max: number, count = 5): number[] { + const lo = Math.min(0, min); + const hi = Math.max(lo + 1e-9, max); + const raw = (hi - lo) / Math.max(count, 1); + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 5, 10].map((m) => m * mag).find((v) => v >= raw) ?? raw; + const out: number[] = []; + for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-6; v += step) + out.push(Math.round(v * 1e6) / 1e6); + if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step); + return out; +} diff --git a/E03_StabSection/logic/E03_StabSection_Prob.ts b/E03_StabSection/logic/E03_StabSection_Prob.ts new file mode 100644 index 000000000..5d810d405 --- /dev/null +++ b/E03_StabSection/logic/E03_StabSection_Prob.ts @@ -0,0 +1,266 @@ +/* ============================================================================= + * E03_StabSection_Prob.ts + * E03 「확률 · 민감도」 탭 계산(PLAN 537-1) — 변수 줄(단면 물성 × c · φ · γ + 지하수위 · r_u) · + * 기본값 후보(서버 `prob_defaults.json`) · 입력 캐시 → 요청 꼴 · 그래프 눈금. + * 화면 · fetch · sessionStorage 없음(페이지가 부름). + * ========================================================================== */ + +export type ProbProp = "c" | "phi" | "gamma" | "water" | "ru"; +export type ProbDist = "normal" | "lognormal"; +export type ProbMethod = "ordinary" | "bishop" | "janbu" | "spencer" | "morgenstern_price"; + +export interface ProbPropDefault { + dist: ProbDist; + cov?: number; + std?: number; + trunc_sigma: number | null; +} + +export interface ProbDefaults { + run: { + n: number; + sampling: "lhs" | "mc"; + seed: number; + mode: "fixed" | "search"; + method: ProbMethod; + }; + properties: Record; + rho_c_phi: number; + sources: Record; + materials?: Record>>>; +} + +export interface ProbMaterial { + id: string; + name: string | null; + c: number; + phi: number; + gamma_t: number; +} + +export interface ProbStats { + n: number; + n_valid: number; + mean: number | null; + std: number | null; + cov?: number | null; + min?: number; + max?: number; + pf: number | null; + n_fail?: number; + beta_normal: number | null; + beta_lognormal?: number | null; + required?: number | null; + p_below_required?: number | null; + histogram?: { edges: number[]; counts: number[] }; + cdf?: { p: number[]; F: number[] }; +} + +export interface ProbCurve { + name: string; + x: (number | null)[]; + F: (number | null)[]; + F_lo: number | null; + F_hi: number | null; + swing: number | null; +} + +export interface ProbVariable { + name: string; + material: string | null; + prop: ProbProp; + dist: ProbDist; + mean: number; + std: number; + lo: number | null; + hi: number | null; +} + +export interface ProbResult { + mode: "fixed" | "search"; + method: ProbMethod; + n: number; + sampling: string; + seed: number; + variables: ProbVariable[]; + stats: ProbStats; + sensitivity: { + F_mean: number | null; + curves: ProbCurve[]; + tornado: { name: string; F_lo: number | null; F_hi: number | null; swing: number }[]; + }; + elapsed_s: { total: number; samples: number }; +} + +export interface ProbRecord { + station_key: string; + side: string; + condition: string; + required: number | null; + fs_deterministic: number | null; + request: Record; + result: ProbResult; + computed_at: string; +} + +export interface ProbSetup { + station_key: string; + side: string; + condition: string; + role: "cut" | "fill"; + required: number | null; + fs_deterministic: Record; + materials: ProbMaterial[]; + groundwater: { mode: "none" | "line" | "ru"; ru: number | null }; + defaults: ProbDefaults; + search_max_n: number; + last: ProbRecord | null; +} + +/** 변수 줄 하나 — kind cov = 변동계수(평균 곱) · std = 표준편차(m · r_u) */ +export interface ProbRow { + key: string; + material: string | null; + prop: ProbProp; + name: string; + mean: number; + kind: "cov" | "std"; +} + +export interface ProbInput { + use: boolean; + dist: ProbDist; + value: number; + trunc: number | null; +} + +export interface ProbRunInput { + n: number; + sampling: "lhs" | "mc"; + seed: number; + mode: "fixed" | "search"; + method: ProbMethod; + rho: number; +} + +export interface ProbCache { + vars: Record; + run: ProbRunInput; +} + +export const varKey = (material: string | null, prop: ProbProp): string => + `${material ?? "*"}|${prop}`; + +/** 단면 물성마다 c · φ · γ + 지하수 갈래가 수위선이면 지하수위 · r_u 면 r_u */ +export function probRows(setup: ProbSetup): ProbRow[] { + const rows: ProbRow[] = []; + for (const m of setup.materials) { + const name = m.name ?? m.id; + rows.push( + { key: varKey(m.id, "c"), material: m.id, prop: "c", name, mean: m.c, kind: "cov" }, + { key: varKey(m.id, "phi"), material: m.id, prop: "phi", name, mean: m.phi, kind: "cov" }, + { + key: varKey(m.id, "gamma"), + material: m.id, + prop: "gamma", + name, + mean: m.gamma_t, + kind: "cov", + }, + ); + } + const gw = setup.groundwater; + if (gw.mode === "line") + rows.push({ + key: varKey(null, "water"), + material: null, + prop: "water", + name: "", + mean: 0, + kind: "std", + }); + if (gw.mode === "ru") + rows.push({ + key: varKey(null, "ru"), + material: null, + prop: "ru", + name: "", + mean: gw.ru ?? 0, + kind: "std", + }); + return rows; +} + +/** 기본값 후보 — 성질 기본 위에 물성별 덮어쓰기 · 0 이면 끔 */ +export function defaultInput(defaults: ProbDefaults, row: ProbRow): ProbInput { + const base = defaults.properties[row.prop]; + const own = (row.material && defaults.materials?.[row.material]?.[row.prop]) || {}; + const merged = { ...base, ...own }; + const value = Number((row.kind === "cov" ? merged.cov : merged.std) ?? 0); + return { + use: value > 0, + dist: row.prop === "water" ? "normal" : merged.dist, + value, + trunc: merged.trunc_sigma ?? null, + }; +} + +/** 캐시가 없는 줄만 기본값 후보로 채움 */ +export function fillCache(setup: ProbSetup, cache: ProbCache | null): ProbCache { + const d = setup.defaults; + const vars: Record = { ...(cache?.vars ?? {}) }; + for (const row of probRows(setup)) vars[row.key] ??= defaultInput(d, row); + const run = cache?.run ?? { ...d.run, rho: d.rho_c_phi }; + return { vars, run }; +} + +/** 기본값과 다른 줄 수(「사용자 입력」 표기) */ +export function changedCount(setup: ProbSetup, cache: ProbCache): number { + return probRows(setup).filter((row) => { + const a = defaultInput(setup.defaults, row); + const b = cache.vars[row.key]; + return ( + b && (a.use !== b.use || a.dist !== b.dist || a.value !== b.value || a.trunc !== b.trunc) + ); + }).length; +} + +/** POST …/stab/prob/{측점} 몸 — 켠 줄만 · 값 0 은 뺌 */ +export function probRequest(setup: ProbSetup, cache: ProbCache): Record { + const variables = probRows(setup) + .map((row) => ({ row, v: cache.vars[row.key] })) + .filter(({ v }) => v?.use && v.value > 0) + .map(({ row, v }) => ({ + material: row.material, + prop: row.prop, + dist: v.dist, + ...(row.kind === "cov" ? { cov: v.value } : { std: v.value }), + trunc_sigma: v.trunc, + })); + const r = cache.run; + return { + side: setup.side, + condition: setup.condition, + method: r.method, + mode: r.mode, + n: Math.round(r.n), + sampling: r.sampling, + seed: Math.round(r.seed), + rho_c_phi: r.rho, + variables, + }; +} + +/** 그래프 눈금 — 0 을 억지로 넣지 않음(안전율 축) */ +export function axisTicks(min: number, max: number, count = 5): number[] { + const lo = Math.min(min, max); + const hi = Math.max(lo + 1e-9, max); + const raw = (hi - lo) / Math.max(count, 1); + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 5, 10].map((m) => m * mag).find((v) => v >= raw) ?? raw; + const out: number[] = []; + for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-6; v += step) + out.push(Math.round(v * 1e6) / 1e6); + if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step); + return out; +} diff --git a/E03_StabSection/logic/E03_StabSection_Support.ts b/E03_StabSection/logic/E03_StabSection_Support.ts new file mode 100644 index 000000000..fb26edeca --- /dev/null +++ b/E03_StabSection/logic/E03_StabSection_Support.ts @@ -0,0 +1,221 @@ +/* ============================================================================= + * E03_StabSection_Support.ts + * E03 보강 검토 네일 · 앵커 화면 계산(PLAN 536-3 뒷부분) — 화면 · fetch · sessionStorage 없음 · 순수 함수만. + * 배치(행 수 · 첫 행 높이 · 수직 · 수평 간격 · 제원) → supports 목록(서버 POST whatif 입력 꼴 그대로) · + * 네일 선 그림 좌표(머리 · 끝 · 정착 구간 · 임계 활동면과 만나는 점). + * 좌표 = 서버 결과 그대로(x = B06 offset · 좌 + · z = 표고). 수평 방향 = 그 비탈 활동 방향 반대(비탈 속으로). + * 높이 = 비탈 밑에서 위로 — 깎기 밑 = 노면(중심 표고) · 쌓기 밑 = 노면 − 비탈 높이. + * ========================================================================== */ + +import type { Point } from "./E03_StabSection_Logic"; + +export type SupportType = "nail" | "anchor"; +export type SupportMode = "tensile" | "pullout" | "stripping" | "not_crossing" | "prestress"; + +/** 서버 · 엔진 입력 꼴(브레인 정함 · sub4 엔진 같은 이름) */ +export interface SupportIn { + type: SupportType; + side: string; + x: number; + z: number; + /** 수평 아래로 + */ + angle_deg: number; + length_m: number; + /** 종방향 간격 — 단위 폭 힘 = 힘 / 간격 */ + spacing_m: number; + tensile_kN: number; + /** 그라우트 · 지반 부착(인발 안전율 넣은 값 · 엔진이 안 나눔) — 네일 = 전 길이 · 앵커 = 정착장만 */ + bond_kPa: number; + /** 앵커 정착장 · 네일은 무시 */ + bond_length_m: number; + hole_dia_mm: number; + /** 앵커만 · 0 = 수동 */ + prestress_kN: number; +} + +export interface SupportForce { + index: number; + mobilized_kN_per_m: number; + mode: SupportMode; +} + +/** 화면 배치 입력 */ +export interface SupportLayout { + type: SupportType; + rows: number; + first_m: number; + v_spacing_m: number; + h_spacing_m: number; + length_m: number; + angle_deg: number; + tensile_kN: number; + bond_kPa: number; + bond_length_m: number; + hole_dia_mm: number; + prestress_kN: number; +} + +/** 기본값 후보(사용자 확정 전) — 지식DB 안정성평가 02 §4(천공 φ100 · 풍화토 주면마찰 50~100 kPa · + * SD400 D25 허용 0.6 fy·As = 121.6 kN · 부착 = 극한 주면마찰 ÷ 인발 안전율 2.0) · + * 앵커 = 강연선 φ12.7 × 4 허용 0.6 fpu = 441 kN · 풍화암 주면마찰 150 kPa ÷ 2.0 흔한 값 */ +export const SUPPORT_DEFAULTS: Record = { + nail: { + type: "nail", + rows: 3, + first_m: 1.0, + v_spacing_m: 1.5, + h_spacing_m: 1.5, + length_m: 6.0, + angle_deg: 15, + tensile_kN: 121.6, + bond_kPa: 40, + bond_length_m: 0, + hole_dia_mm: 100, + prestress_kN: 0, + }, + anchor: { + type: "anchor", + rows: 2, + first_m: 1.5, + v_spacing_m: 2.5, + h_spacing_m: 2.5, + length_m: 12.0, + angle_deg: 30, + tensile_kN: 441, + bond_kPa: 75, + bond_length_m: 6.0, + hole_dia_mm: 115, + prestress_kN: 200, + }, +}; + +/** 비탈 면 위 표고 z 인 점의 x — 노면 중심(x = 0)에서 그 쪽 바깥으로 처음 만나는 곳 */ +export function faceX(surface: Point[], side: string, z: number): number | null { + const s = side === "left" ? 1 : -1; + const pts = surface + .filter((p) => s * p[0] >= 0) + .slice() + .sort((a, b) => s * a[0] - s * b[0]); + for (let i = 1; i < pts.length; i += 1) { + const [a, b] = [pts[i - 1], pts[i]]; + if ((a[1] - z) * (b[1] - z) <= 0 && a[1] !== b[1]) { + return a[0] + ((z - a[1]) / (b[1] - a[1])) * (b[0] - a[0]); + } + } + return null; +} + +function roadZ(surface: Point[]): number | null { + const pts = surface.slice().sort((a, b) => a[0] - b[0]); + for (let i = 1; i < pts.length; i += 1) { + const [a, b] = [pts[i - 1], pts[i]]; + if (a[0] <= 0 && b[0] >= 0) + return b[0] === a[0] ? a[1] : a[1] + (-a[0] / (b[0] - a[0])) * (b[1] - a[1]); + } + return null; +} + +/** 배치 → supports — 행 높이 = 첫 행 + i × 수직 간격(비탈 높이 안만) · 머리 = 그 높이의 비탈 면 */ +export function layoutSupports( + surface: Point[], + side: string, + role: "cut" | "fill", + heightM: number | null, + layout: SupportLayout, +): SupportIn[] { + const road = roadZ(surface); + if (road === null) return []; + const H = heightM ?? Infinity; + const base = role === "cut" ? road : road - (heightM ?? 0); + const out: SupportIn[] = []; + for (let i = 0; i < Math.max(0, Math.floor(layout.rows)); i += 1) { + const h = layout.first_m + i * layout.v_spacing_m; + if (h <= 0 || h >= H) break; + const z = base + h; + const x = faceX(surface, side, z); + if (x === null) break; + out.push({ + type: layout.type, + side, + x: round(x), + z: round(z), + angle_deg: layout.angle_deg, + length_m: layout.length_m, + spacing_m: layout.h_spacing_m, + tensile_kN: layout.tensile_kN, + bond_kPa: layout.bond_kPa, + bond_length_m: layout.type === "anchor" ? layout.bond_length_m : 0, + hole_dia_mm: layout.hole_dia_mm, + prestress_kN: layout.type === "anchor" ? layout.prestress_kN : 0, + }); + } + return out; +} + +const round = (v: number): number => Math.round(v * 1000) / 1000; + +export interface SupportDraw { + index: number; + type: SupportType; + head: Point; + tail: Point; + /** 부착 구간 — 네일 = 전 길이 · 앵커 = 끝에서 정착장 */ + bond: [Point, Point]; + /** 임계 활동면과 만나는 점(없으면 null) */ + cross: Point | null; + /** 결과 mode · 결과 없음 = "pending" */ + mode: SupportMode | "pending"; + force: number | null; +} + +/** 두 선분 교점(a→b 위 비율 t 와 함께) */ +function hit(a: Point, b: Point, c: Point, d: Point): [number, Point] | null { + const r = [b[0] - a[0], b[1] - a[1]]; + const q = [d[0] - c[0], d[1] - c[1]]; + const den = r[0] * q[1] - r[1] * q[0]; + if (Math.abs(den) < 1e-12) return null; + const t = ((c[0] - a[0]) * q[1] - (c[1] - a[1]) * q[0]) / den; + const u = ((c[0] - a[0]) * r[1] - (c[1] - a[1]) * r[0]) / den; + if (t < 0 || t > 1 || u < 0 || u > 1) return null; + return [t, [a[0] + t * r[0], a[1] + t * r[1]]]; +} + +/** 선분이 꺾은선과 처음(머리 쪽) 만나는 점 */ +export function crossPoint(head: Point, tail: Point, line: Point[]): Point | null { + let best: [number, Point] | null = null; + for (let i = 1; i < line.length; i += 1) { + const h = hit(head, tail, line[i - 1], line[i]); + if (h && (!best || h[0] < best[0])) best = h; + } + return best ? best[1] : null; +} + +/** 그림 좌표 — direction = 그 비탈 활동 방향(+1 = +x 쪽으로 미끄러짐) */ +export function supportDraws( + supports: SupportIn[], + direction: number, + critical: Point[] | null, + forces: SupportForce[] | null | undefined, +): SupportDraw[] { + const into = direction >= 0 ? -1 : 1; + return supports.map((s, index) => { + const a = (s.angle_deg * Math.PI) / 180; + const ux = into * Math.cos(a); + const uz = -Math.sin(a); + const head: Point = [s.x, s.z]; + const at = (d: number): Point => [round(s.x + ux * d), round(s.z + uz * d)]; + const tail = at(s.length_m); + const bondFrom = s.type === "anchor" ? Math.max(0, s.length_m - s.bond_length_m) : 0; + const f = forces?.find((v) => v.index === index) ?? null; + return { + index, + type: s.type, + head, + tail, + bond: [at(bondFrom), tail], + cross: critical ? crossPoint(head, tail, critical) : null, + mode: f ? f.mode : "pending", + force: f ? f.mobilized_kN_per_m : null, + }; + }); +} diff --git a/E04_Stab3D/E04_Stab3D_Engine_Column.py b/E04_Stab3D/E04_Stab3D_Engine_Column.py new file mode 100644 index 000000000..4402df635 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_Engine_Column.py @@ -0,0 +1,414 @@ +"""E04 3D 기둥 솔버 — Hovland · Hungr 3D Bishop 간편 · Hungr 3D Janbu 간편 + 보정. + +근거(수식만 · 코드는 자체): +- Reid 외(2015) USGS TM 14-A1 Scoops3D 2.1~2.5 — 기둥 기하 · 모멘트 평형 · 간극수압 · 특수 경우. +- Hungr(1987) Géotechnique 37(1) · Hungr·Salgado·Byrne(1989) CGJ 26(4) — 3D Bishop · 3D Janbu. +- Hovland(1977) ASCE GT9 — 기둥 사이 힘 0 · N = W cosψ. +- Janbu(1973) 보정 계수 f0 = 1 + b1 (d/L − 1.4 (d/L)²). +- 지식DB `original/안정성평가/05_3D_사면안정해석_논문_기술정보.md` 1장. + +꼴: 기둥은 납작한 배열 + `group`(활동체 번호) — 여러 활동체를 한 번에 셈(np.bincount). +좌표: 활동 방향 d(수평 단위) · 회전축 = 중심을 지나는 수평선 ⊥ d. +모멘트(내리미는 쪽 +) — 자중 −p·W · 지진 e·kh·W · 수직력 N·m_n · 저항 전단 −S·arm_s. + p = (P − C)·d · e = 축 높이 − 기둥 무게중심 높이 · g = 바닥면의 d 방향 기울기 + arm_s = (p·g − z_b')/√(1+g²) · m_n = cosψ (p + z_b'·g) + 구 · 원형 단면 타원체는 수직력이 축을 지나 m_n = 0 → Scoops3D 식 2.22 그대로. +Bishop 수직력(식 2.31): N = (W+Q − (cA − uA tanφ) sinα / F) / mα · mα = cosψ + sinα tanφ / F + → 저항 S_res = [c A_h + (W+Q − u A_h) tanφ] / mα (식 2.33 · A_h = 수평 면적). +Janbu 힘 평형(d 방향): F0 = Σ S_res / cosα ÷ Σ((W+Q) tanα + kh W) · 보정 F = f0 F0. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +from scipy import ndimage + +from E03_StabSection.E03_StabSection_Types import GAMMA_W, Groundwater, Material, groundwater + +MA_MIN = 0.2 # mα 하한 경고(Duncan & Wright) — 밑이면 결과에 표시 +F_CAP = 100.0 # Scoops3D 2.5 — 너무 큰 F 는 100 +TOL = 1e-4 +MAX_ITER = 50 + + +@dataclass +class Strata: + """지층 격자 — 셀 중심 축 x(nx) · y(ny) · 지표 z_top(ny, nx) · 층 바닥(K−1, ny, nx). + + 층 k 는 위(층 k−1 바닥 · 0 층은 지표)부터 bottoms[k] 까지 · 맨 아래 층은 끝없음. + hard[k] = 활동면이 못 뚫는 층(암반) — 그 윗면에서 잘림(Search 머리 주석 규칙). + z_water = 지하수위면(mode "line" 의 3D 꼴) · q = 상재하중(kPa) 격자. + """ + + x: np.ndarray + y: np.ndarray + z_top: np.ndarray + bottoms: np.ndarray + materials: list[Material] + hard: list[bool] = field(default_factory=list) + z_water: np.ndarray | None = None + q: np.ndarray | None = None + + def __post_init__(self) -> None: + self.x = np.asarray(self.x, float) + self.y = np.asarray(self.y, float) + self.z_top = np.asarray(self.z_top, float) + self.bottoms = np.asarray(self.bottoms, float).reshape(-1, *self.z_top.shape) + if len(self.materials) != len(self.bottoms) + 1: + raise ValueError("물성 수 = 층 바닥 수 + 1 이어야 함") + self.hard = list(self.hard) or [False] * len(self.materials) + self.dx = float(self.x[1] - self.x[0]) + if abs(float(self.y[1] - self.y[0]) - self.dx) > 1e-4 * self.dx: + raise ValueError("격자 간격은 x · y 같아야 함") + # 층 바닥은 지표 아래 · 위에서 아래로 내려감 + tops = np.concatenate([self.z_top[None], self.bottoms]) + self.bottoms = np.minimum.accumulate(tops, axis=0)[1:] + self.gamma_t = np.array([m["gamma_t"] for m in self.materials]) + self.gamma_sat = np.array([m["gamma_sat"] for m in self.materials]) + self.c = np.array([m["c"] for m in self.materials]) + self.tan_phi = np.tan(np.radians([m["phi"] for m in self.materials])) + # 못 뚫는 층 윗면(없으면 −inf) · 그 기울기 + tops = np.concatenate([self.z_top[None], self.bottoms]) + first = next((k for k, h in enumerate(self.hard) if h), None) + hard_top = np.full(self.z_top.shape, -np.inf) if first is None else tops[first].copy() + self.hard_top = hard_top + finite = np.where(np.isfinite(hard_top), hard_top, 0.0) + self.hard_gy, self.hard_gx = np.gradient(finite, self.dx) + self.gy, self.gx = np.gradient(self.z_top, self.dx) + # 가장자리 · 빈칸 이웃 — 여기 닿은 활동체는 질량이 잘려 버림(Search) + edge = ndimage.binary_dilation(~np.isfinite(self.z_top)) + edge[[0, -1], :] = True + edge[:, [0, -1]] = True + self.edge = edge + self._z_pad = np.pad(self.z_top, 1, mode="edge") + self._shifted: dict = {} + + def ground_at(self, iy: np.ndarray, ix: np.ndarray, fy: float, fx: float) -> np.ndarray: + """셀 (iy, ix) 중심에서 (fy, fx) 칸만큼 옮긴 점의 지표(쌍선형 · |f| ≤ 0.5).""" + if fx == 0 and fy == 0: + return self.z_top[iy, ix] + key = (fy, fx) + if key not in self._shifted: # 옮긴 점 지표 격자를 한 번 만들어 둠 + sx, sy = int(np.sign(fx)), int(np.sign(fy)) + ax, ay = abs(fx), abs(fy) + zp = self._z_pad + ny, nx = self.z_top.shape + z00 = zp[1 : ny + 1, 1 : nx + 1] + z01 = zp[1 : ny + 1, 1 + sx : nx + 1 + sx] + z10 = zp[1 + sy : ny + 1 + sy, 1 : nx + 1] + z11 = zp[1 + sy : ny + 1 + sy, 1 + sx : nx + 1 + sx] + self._shifted[key] = (1 - ay) * ((1 - ax) * z00 + ax * z01) + ay * ( + (1 - ax) * z10 + ax * z11 + ) + return self._shifted[key][iy, ix] + + +@dataclass +class Columns: + """활동체 묶음의 기둥 — 납작한 배열(길이 n) · group 은 0..G−1.""" + + iy: np.ndarray + ix: np.ndarray + group: np.ndarray + z_slip: np.ndarray # 시험 활동면 높이(잘리기 전) + gx: np.ndarray # 활동면 기울기 ∂z/∂x + gy: np.ndarray # ∂z/∂y + center: np.ndarray # (G, 3) 회전축이 지나는 점 + direction: np.ndarray | None = None # (G, 2) 활동 방향 · None 이면 지표 낙하 방향(Scoops3D) + # 부분 기둥(셀 안 표본) — 대표 위치 · 걸친 비율 · 걸친 부분 평균 깊이 + # None = 셀 중심 · 한 칸 · 지표 − 바닥 + px: np.ndarray | None = None + py: np.ndarray | None = None + frac: np.ndarray | None = None + depth: np.ndarray | None = None + + +def _gsum(group: np.ndarray, values: np.ndarray, n: int) -> np.ndarray: + return np.bincount(group, weights=values, minlength=n) + + +def fall_direction(strata: Strata, cols: Columns, n: int) -> np.ndarray: + """활동체 지표의 평균 낙하 방향(Scoops3D 2.1) — (n, 2) 단위 벡터.""" + sx = _gsum(cols.group, -strata.gx[cols.iy, cols.ix], n) + sy = _gsum(cols.group, -strata.gy[cols.iy, cols.ix], n) + norm = np.hypot(sx, sy) + norm[norm == 0] = 1.0 + return np.column_stack([sx / norm, sy / norm]) + + +def _column_state(strata: Strata, cols: Columns, gw: Groundwater, kh: float) -> dict: + """기둥마다 자중 · 바닥 물성 · 간극수압 · 기하 — 활동면을 못 뚫는 층에서 자름.""" + iy, ix, grp = cols.iy, cols.ix, cols.group + hard_top = strata.hard_top[iy, ix] + truncated = cols.z_slip < hard_top + z_b = np.where(truncated, hard_top, cols.z_slip) + gx = np.where(truncated, strata.hard_gx[iy, ix], cols.gx) + gy = np.where(truncated, strata.hard_gy[iy, ix], cols.gy) + z_top = strata.z_top[iy, ix] if cols.depth is None else z_b + cols.depth + depth = np.maximum(z_top - z_b, 0.0) + x = strata.x[ix] if cols.px is None else cols.px + y = strata.y[iy] if cols.py is None else cols.py + + # 층별 두께 → 자중(지하수위 아래는 γsat) · 부분 기둥은 걸친 비율만큼 면적 + a_h = strata.dx**2 * (np.ones(len(iy)) if cols.frac is None else cols.frac) + k_n = len(strata.materials) + tops = np.concatenate([z_top[None], strata.bottoms[:, iy, ix]]) + bots = np.concatenate([strata.bottoms[:, iy, ix], np.full((1, len(iy)), -np.inf)]) + upper = np.minimum(tops, z_top) + lower = np.maximum(bots, z_b) + thick = np.clip(upper - lower, 0.0, None) + if gw.get("mode") == "line" and strata.z_water is not None: + zw = strata.z_water[iy, ix] + wet = np.clip(np.minimum(upper, zw) - lower, 0.0, None) + weight = a_h * (strata.gamma_t[:, None] * (thick - wet) + strata.gamma_sat[:, None] * wet) + else: + weight = a_h * strata.gamma_t[:, None] * thick + w = weight.sum(0) + + # 바닥이 놓인 층 — 경계 위면 위층(잘린 면 = 암반 위 흙의 강도) + layer = (strata.bottoms[:, iy, ix] >= z_b + 1e-9).sum(0) if k_n > 1 else np.zeros(len(iy), int) + c = strata.c[layer] + tan_phi = strata.tan_phi[layer] + + q = strata.q[iy, ix] * a_h if strata.q is not None else np.zeros_like(w) + mode = gw.get("mode", "none") + if mode == "ru": + u_ah = gw["ru"] * w # Scoops3D 식 2.37 — u A_h = ru W + elif mode == "line" and strata.z_water is not None: + u_ah = GAMMA_W * np.maximum(strata.z_water[iy, ix] - z_b, 0.0) * a_h + else: + u_ah = np.zeros_like(w) + + n_g = len(cols.center) + d = cols.direction if cols.direction is not None else fall_direction(strata, cols, n_g) + dxg, dyg = d[grp, 0], d[grp, 1] + xc, yc, zc = cols.center[grp, 0], cols.center[grp, 1], cols.center[grp, 2] + p = (x - xc) * dxg + (y - yc) * dyg + g = gx * dxg + gy * dyg + sec_g = np.sqrt(1.0 + g * g) + cos_psi = 1.0 / np.sqrt(1.0 + gx * gx + gy * gy) + zr = z_b - zc + return { + "w": w, + "q": q, + "c": c, + "tan_phi": tan_phi, + "u_ah": u_ah, + "a_h": a_h, + "cos_psi": cos_psi, + "sin_a": -g / sec_g, + "cos_a": 1.0 / sec_g, + "tan_a": -g, + "p": p, + "arm_s": (p * g - zr) / sec_g, + "m_n": cos_psi * (p + zr * g), + "e": zc - 0.5 * (z_top + z_b), + "kh": kh, + "depth": depth, + "z_b": z_b, + "z_top": z_top, + "truncated": truncated, + "direction": d, + "group": grp, + "n": n_g, + "layer": layer, + } + + +def _s_res(st: dict, f: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Bishop 수직력에서 바닥 저항력 S_res(F) · mα (Scoops3D 식 2.33 · 2.43).""" + fg = np.maximum(f[st["group"]], 1e-3) + m_a = st["cos_psi"] + st["sin_a"] * st["tan_phi"] / fg + fric = np.maximum(st["w"] + st["q"] - st["u_ah"], 0.0) * st["tan_phi"] + return (st["c"] * st["a_h"] + fric) / m_a, m_a + + +def _hovland(st: dict) -> np.ndarray: + """N = (W+Q) cosψ · 기둥 사이 힘 0 — 반복 없음(Hovland 1977 · 지식DB 05 식 ①).""" + grp, n = st["group"], st["n"] + a = st["a_h"] / st["cos_psi"] + wq = st["w"] + st["q"] + nn = wq * st["cos_psi"] + fric = np.maximum(nn - st["u_ah"] / st["cos_psi"], 0.0) * st["tan_phi"] + res = _gsum(grp, st["arm_s"] * (st["c"] * a + fric), n) + drive = _gsum(grp, -st["p"] * wq + st["e"] * st["kh"] * st["w"] + nn * st["m_n"], n) + return _finish(res, drive) + + +def _finish(res: np.ndarray, drive: np.ndarray) -> np.ndarray: + out = np.full(res.shape, F_CAP) + ok = drive > 0 + out[ok] = np.minimum(np.maximum(res[ok], 0.0) / drive[ok], F_CAP) + return out + + +def _fixed_point(st: dict, f: np.ndarray, coef: dict, step) -> tuple[np.ndarray, np.ndarray]: + """F = step(기둥 묶음, F) 고정점 — 수렴한 활동체의 기둥은 빼며 셈(값은 빼지 않을 때와 같음). + + coef = 기둥별 상수 배열(group 포함) · 반환 (F, 수렴 여부). + """ + n = st["n"] + sub = coef + conv = np.zeros(n, bool) + for _ in range(MAX_ITER): + f_new = step(sub, f) + done = np.abs(f_new - f) < TOL + f = np.where(conv, f, f_new) + conv |= done + if conv.all(): + break + live = ~conv[sub["group"]] + if live.mean() < 0.6: # 남은 기둥만 — 매번 줄이면 줄이는 값이 더 듦 + sub = {k: v[live] for k, v in sub.items()} + return f, conv + + +def _bishop(st: dict, f0: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + grp, n = st["group"], st["n"] + wq = st["w"] + st["q"] + # 거꾸로 선 바닥(α<0)이 있으면 처음 값을 올림 — Scoops3D 식 2.34 · 2.35 + neg = np.where(st["sin_a"] < 0, -st["sin_a"] * st["tan_phi"] / st["cos_psi"], 0.0) + lift = np.zeros(n) + np.maximum.at(lift, grp, neg) + f = np.where(np.isfinite(f0), np.maximum(f0, 0.05), 1.0) + lift + # S_res = num / mα · N = (W+Q − S sinα)/cosψ → 구동 = d1 − Σ S_res·k_n / F (기둥별 상수 미리) + num = st["c"] * st["a_h"] + np.maximum(wq - st["u_ah"], 0.0) * st["tan_phi"] + d1 = _gsum( + grp, -st["p"] * wq + st["e"] * st["kh"] * st["w"] + wq * st["m_n"] / st["cos_psi"], n + ) + coef = { + "group": grp, + "num": num, + "cos_psi": st["cos_psi"], + "k": st["sin_a"] * st["tan_phi"], + "arm_s": st["arm_s"], + "k_n": st["sin_a"] * st["m_n"] / st["cos_psi"], + } + + def step(c: dict, f: np.ndarray) -> np.ndarray: + g = c["group"] + fg = np.maximum(f, 1e-3) + s_res = c["num"] / (c["cos_psi"] + c["k"] / fg[g]) + res = _gsum(g, c["arm_s"] * s_res, n) + drive = d1 - _gsum(g, s_res * c["k_n"], n) / fg + return _finish(res, drive) + + f, conv = _fixed_point(st, f, coef, step) + _, m_a = _s_res(st, f) + ma_min = _gmin(grp, m_a, n) + # 못 쓰는 면 — 수렴 안 함 · mα ≤ 0(바닥 수직력이 음) · F ≤ 0 → nan(후보에서 빠짐) + f[~conv | (ma_min <= 0) | (f <= 0)] = np.nan + return f, ma_min + + +def _gmin(grp: np.ndarray, values: np.ndarray, n: int) -> np.ndarray: + out = np.full(n, np.inf) + np.minimum.at(out, grp, np.nan_to_num(values, nan=-np.inf)) + return out + + +def _janbu(st: dict, f0: np.ndarray) -> np.ndarray: + grp, n = st["group"], st["n"] + wq = st["w"] + st["q"] + drive = _gsum(grp, wq * st["tan_a"] + st["kh"] * st["w"], n) + f = np.where(np.isfinite(f0), np.maximum(f0, 0.05), 1.0) + num = st["c"] * st["a_h"] + np.maximum(wq - st["u_ah"], 0.0) * st["tan_phi"] + coef = { + "group": grp, + "num": num / st["cos_a"], + "cos_psi": st["cos_psi"], + "k": st["sin_a"] * st["tan_phi"], + } + + def step(c: dict, f: np.ndarray) -> np.ndarray: + g = c["group"] + s_res = c["num"] / (c["cos_psi"] + c["k"] / np.maximum(f, 1e-3)[g]) + return _finish(_gsum(g, s_res, n), drive) + + f, conv = _fixed_point(st, f, coef, step) + f[~conv | (f <= 0)] = np.nan + return f + + +def janbu_f0(st: dict) -> np.ndarray: + """Janbu(1973) 보정 계수 — d/L 은 활동 방향 단면(현 = 윗끝 · 아랫끝 지표를 잇는 선)에서. + + b1 = 0.69(c=0) · 0.31(φ=0) · 0.50(c−φ). L = 활동 방향 길이 · d = 바닥에서 현까지 가장 먼 거리. + """ + grp, n = st["group"], st["n"] + s = st["p"] # 축 기준 d 방향 위치(같은 묶음 안 비교만 씀) + lo = np.full(n, np.inf) + hi = np.full(n, -np.inf) + np.minimum.at(lo, grp, s) + np.maximum.at(hi, grp, s) + # 양 끝 지표 높이 — 끝 기둥들 가운데 가장 얕은 바닥 위 지표 + at_lo = s <= lo[grp] + 1e-9 + at_hi = s >= hi[grp] - 1e-9 + z_lo = np.full(n, -np.inf) + z_hi = np.full(n, -np.inf) + np.maximum.at(z_lo, grp[at_lo], st["z_top"][at_lo]) + np.maximum.at(z_hi, grp[at_hi], st["z_top"][at_hi]) + length = np.maximum(hi - lo, 1e-9) + # 현: (lo, z_lo) → (hi, z_hi) · 바닥점과의 수직 거리 + dz = z_hi - z_lo + norm = np.hypot(length, dz) + dist = np.abs((s - lo[grp]) * dz[grp] - (st["z_b"] - z_lo[grp]) * length[grp]) / norm[grp] + depth = np.zeros(n) + np.maximum.at(depth, grp, dist) + ratio = depth / norm + has_c = _gsum(grp, (st["c"] > 0).astype(float), n) > 0 + has_phi = _gsum(grp, (st["tan_phi"] > 0).astype(float), n) > 0 + b1 = np.where(has_c & has_phi, 0.50, np.where(has_phi, 0.69, 0.31)) + return 1.0 + b1 * (ratio - 1.4 * ratio**2) + + +def solve( + strata: Strata, + cols: Columns, + groundwater_raw: dict | None = None, + seismic_kh: float = 0.0, +) -> dict: + """활동체마다 안전율 — hovland · bishop · janbu(간편) · janbu_corrected · 부피 · 잘림 비율. + + group 은 0..G−1 · 활동면이 지표 아래인 기둥만 셈(밖은 버림). + """ + raw = groundwater_raw or {} + # 3D 의 "line" = 지하수위면 격자(Strata.z_water) — 2D 선(line 칸)은 없어도 됨 + gw = {"mode": "line"} if raw.get("mode") == "line" and "line" not in raw else groundwater(raw) + if gw["mode"] == "line" and strata.z_water is None: + raise ValueError("지하수 mode line — Strata.z_water(지하수위면 격자)가 없음") + st = _column_state(strata, cols, gw, float(seismic_kh)) + keep = st["depth"] > 0 + n = st["n"] + st = { + k: (v[keep] if isinstance(v, np.ndarray) and v.shape == keep.shape else v) + for k, v in st.items() + } + grp = st["group"] + count = np.bincount(grp, minlength=n) + f_hov = _hovland(st) + f_bis, ma_min = _bishop(st, f_hov) + f_jan = _janbu(st, f_bis) + f0 = janbu_f0(st) + area = _gsum(grp, st["a_h"], n) + trunc_area = _gsum(grp, st["truncated"] * st["a_h"], n) + empty = count == 0 + out = { + "hovland": f_hov, + "bishop": f_bis, + "janbu": f_jan, + "janbu_corrected": f_jan * f0, + "janbu_f0": f0, + "volume": _gsum(grp, st["depth"] * st["a_h"], n), + "weight": _gsum(grp, st["w"], n), + "area": area, + "truncated_ratio": np.divide(trunc_area, area, out=np.zeros(n), where=area > 0), + "ma_min": ma_min, + "columns": count, + "direction": st["direction"], + } + for key in ("hovland", "bishop", "janbu", "janbu_corrected"): + out[key] = np.where(empty, np.nan, out[key]) + return out diff --git a/E04_Stab3D/E04_Stab3D_Engine_Grid.py b/E04_Stab3D/E04_Stab3D_Engine_Grid.py new file mode 100644 index 000000000..97ea45d73 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_Engine_Grid.py @@ -0,0 +1,310 @@ +"""E04 3D 입력 격자 — 합성 DEM · 지층 경계 격자 · 노면 하중 격자. + +합성 DEM(원지형 + B06 깎기 · 쌓기): +- 측점 i · i+1 횡단선을 t 로 잇는 선직면에 셀을 앉힘 + — P = C(t) + o·L(t) 를 t 의 2차식으로 풂 · 측점 위(t=0 · 1)는 횡단선 그대로. +- δ(o) = 설계선 z − 원지형 z(같은 offset) · 측점 사이는 t 선형 · 합성 = 원지형 + δ. + → 측점 위 합성 단면 = B06 설계선 · δ=0 인 곳과 띠 밖은 원지형 그대로. +- 띠 = 두 측점 설계선 offset 범위의 합(B06 ±20 m · 사면이 안 닫히면 더 넓음) + · 한쪽 설계선 밖은 δ=0 으로 이음 · 두 구간에 걸린 셀은 |o| 작은 쪽. + +지층 경계(정함 · 보고): +- 흙 두께 T = 암 측점 |암 경계 offset|(B06 평행선) · 토사 측점 = 기본 3 m + (국내 산지 풍화토 · 붕적토 1~3 m 의 큰 쪽). +- 띠 안 = 두 측점 T 를 t 선형 · 띠 밖 · 노선 끝 밖 = 가장 가까운 측점 값. +- 암 윗면 = 원지형 − T(깎기로 드러나면 지표) · 암 종류 = 가까운 측점 라벨 + (토사 측점 아래 = 리핑암). +- 층(위→아래) = 성토 · 풍화토 · 리핑암(풍화암 · 뚫림) · 발파암(연암 · 못 뚫음). +노면 하중 = 두 측점 노면 끝(road_edges) 사이(t 보간) 띠 안 셀에 q(kPa). +""" + +from __future__ import annotations + +import numpy as np +from scipy.interpolate import RegularGridInterpolator +from scipy.spatial import cKDTree + +from E03_StabSection.E03_StabSection_Types import DEFAULT_MATERIALS, DEFAULT_ROAD_SURCHARGE_KPA +from E04_Stab3D.E04_Stab3D_Engine_Column import Strata + +DEFAULT_SOIL_THICKNESS_M = 3.0 +CLOSE_STEP_M = 0.5 # 안 닫힌 사면 늘리기 간격 · 상한 +CLOSE_MAX_M = 60.0 +ROCK_KINDS = ("ripping_rock", "blasting_rock") +#: 층 → 물성 id(E03 기본표) · 못 뚫는 층 +LAYER_MATERIALS = ("fill", "weathered_soil", "weathered_rock", "soft_rock") +LAYER_HARD = (False, False, False, True) +LAYER_LABELS = ("fill", "soil", "ripping_rock", "blasting_rock") # E02 label_map 열쇠 + + +def station_from_b06(cross: dict, design: dict) -> dict: + """B06 횡단 파일(`cross_NNNNNm.json`) + 설계(`cross_sections.data.design`) → 측점 한 줄.""" + ground = [[s["offset_m"], s["z"]] for s in cross["samples"] if s.get("valid", True)] + edges = design.get("road_edges") or {} + rb = design.get("rock_boundary_offset_m") + return { + "chainage_m": float(cross["chainage_m"]), + "center": [float(cross["center_x"]), float(cross["center_y"])], + "left": [float(v) for v in cross["frame"]["left_xy"]], + "ground": ground, + "design": [[p["offset_m"], p["elevation_m"]] for p in design["design_line"]], + "ground_type": design.get("ground_type", "soil"), + "rock_depth_m": abs(float(rb)) + if rb is not None and design.get("ground_type") in ROCK_KINDS + else None, + "road": sorted(float(e["offset_m"]) for e in edges.values()) if len(edges) == 2 else None, + } + + +def _delta( + st: dict, center: np.ndarray, left: np.ndarray, ground_at +) -> tuple[np.ndarray, np.ndarray]: + """설계선 offset 위 δ = 설계 − 원지형(쌍선형) · 양 끝 밖 δ(0 · 자료 끝이면 끝 값). + + B06 이 반폭 밖으로 늘린 설계선도 받음. + + 끝에서 사면이 안 닫혔으면(B06 slope_unclosed · |δ| > 5 cm) 끝 기울기로 원지형과 만날 때까지 + 늘림(CLOSE_MAX_M 까지) — 안 늘리면 띠 끝에 절벽이 생겨 가짜 임계 활동면이 됨. + """ + d = np.asarray(st["design"], float) + d = d[np.argsort(d[:, 0], kind="stable")] + pts = center[None] + d[:, :1] * left[None] + delta = d[:, 1] - ground_at(pts) + ok = np.isfinite(delta) + o, z, delta = d[ok, 0], d[ok, 1], delta[ok] + parts_o, parts_d = [o], [delta] + hold = {-1: False, 1: False} + for side in (-1, 1): + k0, k1 = (0, 1) if side < 0 else (-1, -2) + if len(o) < 2 or abs(delta[k0]) <= 0.05 or o[k0] == o[k1]: + continue + slope = (z[k0] - z[k1]) / (o[k0] - o[k1]) + ext = o[k0] + side * np.arange(CLOSE_STEP_M, CLOSE_MAX_M + 1e-9, CLOSE_STEP_M) + de = z[k0] + slope * (ext - o[k0]) - ground_at(center[None] + ext[:, None] * left[None]) + cross = np.flatnonzero(~np.isfinite(de) | (np.sign(de) != np.sign(delta[k0]))) + n = cross[0] if len(cross) else len(ext) + if n < len(ext) and np.isfinite(de[n]): + ext, de = ext[: n + 1], np.append(de[:n], 0.0) # 원지형과 만남 → 0 + else: + ext, de = ext[:n], de[:n] # 자료 끝 · 상한 — 끝 값을 이어 감(build_grid hold) + hold[side] = True + parts_o.append(ext) + parts_d.append(de) + o = np.concatenate(parts_o) + delta = np.concatenate(parts_d) + order = np.argsort(o, kind="stable") + o, delta = o[order], delta[order] + return o, delta, (delta[0] if hold[-1] else 0.0), (delta[-1] if hold[1] else 0.0) + + +def _segment_map(xx, yy, c0, c1, l0, l1): + """선직면 P = C0 + tD + o(L0 + tM) 에서 (t, o[m]) — t∈[0,1] 아닌 곳은 nan.""" + d = c1 - c0 + m = l1 - l0 + qx, qy = xx - c0[0], yy - c0[1] + + def cross(ax, ay, bx, by): + return ax * by - ay * bx + + a = -cross(d[0], d[1], m[0], m[1]) + b = cross(qx, qy, m[0], m[1]) - cross(d[0], d[1], l0[0], l0[1]) + c = cross(qx, qy, l0[0], l0[1]) + if abs(a) < 1e-12: + t = np.where(np.abs(b) > 1e-12, -c / np.where(b == 0, 1, b), np.nan) + else: + disc = np.sqrt(np.maximum(b * b - 4 * a * c, 0.0)) + t1 = (-b + disc) / (2 * a) + t2 = (-b - disc) / (2 * a) + ok1 = (t1 >= -1e-9) & (t1 <= 1 + 1e-9) + t = np.where(ok1, t1, t2) + t = np.where((t >= -1e-9) & (t <= 1 + 1e-9), np.clip(t, 0, 1), np.nan) + lx, ly = l0[0] + t * m[0], l0[1] + t * m[1] + norm = np.hypot(lx, ly) + o = ((qx - t * d[0]) * lx + (qy - t * d[1]) * ly) / norm + return t, o + + +def build_grid( + dem: dict, + stations: list[dict], + *, + bbox: tuple[float, float, float, float] | None = None, + default_soil_thickness_m: float = DEFAULT_SOIL_THICKNESS_M, + road_q_kpa: float = DEFAULT_ROAD_SURCHARGE_KPA, + cell_m: float | None = 1.0, +) -> dict: + """원지형 DEM(x · y · z · valid_mask) + 측점 → 합성 · 지층 격자. bbox=(x0, x1, y0, y1) 로 자름. + + cell_m — 정사각 격자로 다시 뜸(쌍선형 · B04 축은 float32 라 간격이 흔들림) + · None = DEM 축 그대로. + """ + x = np.asarray(dem["x"], float) + y = np.asarray(dem["y"], float) + z = np.asarray(dem["z"], float) + valid = np.asarray(dem.get("valid_mask", np.isfinite(z)), bool) + if x[0] > x[-1]: + x, z, valid = x[::-1], z[:, ::-1], valid[:, ::-1] + if y[0] > y[-1]: + y, z, valid = y[::-1], z[::-1], valid[::-1] + z_orig = np.where(valid, z, np.nan) + # δ 는 원래 DEM 에서 뜸(B06 지반선과 같은 면) — 다시 뜬 격자로 뜨면 cm 잡음이 끝 판정을 흔듦 + interp = RegularGridInterpolator((y, x), z_orig, bounds_error=False, fill_value=np.nan) + if cell_m: + x0, x1, y0, y1 = bbox or (x[0], x[-1], y[0], y[-1]) + x0, y0 = max(x0, x[0]), max(y0, y[0]) + xs = x0 + np.arange(int((min(x1, x[-1]) - x0) // cell_m) + 1) * cell_m + ys = y0 + np.arange(int((min(y1, y[-1]) - y0) // cell_m) + 1) * cell_m + gy_, gx_ = np.meshgrid(ys, xs, indexing="ij") + z_orig = interp(np.column_stack([gy_.ravel(), gx_.ravel()])).reshape(gy_.shape) + x, y = xs, ys + elif bbox is not None: + sx = (x >= bbox[0]) & (x <= bbox[1]) + sy = (y >= bbox[2]) & (y <= bbox[3]) + x, y, z_orig = x[sx], y[sy], z_orig[np.ix_(sy, sx)] + z = z_orig + xx, yy = np.meshgrid(x, y) + sts = sorted(stations, key=lambda s: s["chainage_m"]) + centers = np.array([s["center"] for s in sts], float) + lefts = np.array([s["left"] for s in sts], float) + lefts /= np.hypot(lefts[:, 0], lefts[:, 1])[:, None] + + def ground_at(pts: np.ndarray) -> np.ndarray: + return interp(np.column_stack([pts[:, 1], pts[:, 0]])) + + profiles = [_delta(s, centers[i], lefts[i], ground_at) for i, s in enumerate(sts)] + thick = np.array( + [ + s["rock_depth_m"] if s.get("rock_depth_m") is not None else default_soil_thickness_m + for s in sts + ] + ) + kind = np.array([1 if s.get("ground_type") == "blasting_rock" else 0 for s in sts]) + + seg = np.full(z.shape, -1) + t_map = np.full(z.shape, np.nan) + o_map = np.full(z.shape, np.nan) + dz = np.zeros(z.shape) + road = np.zeros(z.shape, bool) + for i in range(len(sts) - 1): + o0, d0, l0_, r0_ = profiles[i] + o1, d1, l1_, r1_ = profiles[i + 1] + lo, hi = min(o0[0], o1[0]), max(o0[-1], o1[-1]) + # 자료 끝에서 멈춘 쪽은 띠를 더 넓혀 끝 값을 이어 감(빈칸 셀은 합성도 빈칸) + lo -= CLOSE_MAX_M if (l0_ or l1_) else 0.0 + hi += CLOSE_MAX_M if (r0_ or r1_) else 0.0 + reach = max(abs(lo), abs(hi)) + corners = np.array( + [centers[i] + s * reach * lefts[i] for s in (-1, 1)] + + [centers[i + 1] + s * reach * lefts[i + 1] for s in (-1, 1)] + ) + jx = np.flatnonzero((x >= corners[:, 0].min() - 1) & (x <= corners[:, 0].max() + 1)) + jy = np.flatnonzero((y >= corners[:, 1].min() - 1) & (y <= corners[:, 1].max() + 1)) + if not len(jx) or not len(jy): + continue + sub = np.ix_(jy, jx) + t, o = _segment_map(xx[sub], yy[sub], centers[i], centers[i + 1], lefts[i], lefts[i + 1]) + inside = np.isfinite(t) & (o >= lo) & (o <= hi) + better = inside & ( + ~np.isfinite(o_map[sub]) | (np.abs(o) < np.abs(np.nan_to_num(o_map[sub], nan=np.inf))) + ) + if not better.any(): + continue + tb, ob = t[better], o[better] + val = (1 - tb) * np.interp(ob, o0, d0, l0_, r0_) + tb * np.interp(ob, o1, d1, l1_, r1_) + for arr, v in ((seg, i), (t_map, tb), (o_map, ob), (dz, val)): + blk = arr[sub] + blk[better] = v + arr[sub] = blk + r0, r1 = sts[i].get("road"), sts[i + 1].get("road") + if r0 and r1: + a = (1 - tb) * r0[0] + tb * r1[0] + b = (1 - tb) * r0[1] + tb * r1[1] + blk = road[sub] + blk[better] = (ob >= a) & (ob <= b) + road[sub] = blk + + in_band = seg >= 0 + dz = np.where(np.isfinite(z_orig), dz, 0.0) + z_syn = z_orig + dz + + # 흙 두께 · 암 종류 — 띠 안 = t 보간 · 밖 = 가까운 측점 + near = cKDTree(centers).query(np.column_stack([xx.ravel(), yy.ravel()]))[1].reshape(z.shape) + t_safe = np.nan_to_num(t_map) + s_safe = np.maximum(seg, 0) + t_band = (1 - t_safe) * thick[s_safe] + t_safe * thick[np.minimum(s_safe + 1, len(sts) - 1)] + soil_t = np.where(in_band, t_band, thick[near]) + k_band = np.where(t_safe < 0.5, kind[s_safe], kind[np.minimum(s_safe + 1, len(sts) - 1)]) + rock_kind = np.where(in_band, k_band, kind[near]) + return { + "x": x, + "y": y, + "z_orig": z_orig, + "z": z_syn, + "dz": dz, + "in_band": in_band, + "segment": seg, + "t": t_map, + "offset": o_map, + "rock_top": z_orig - soil_t, + "soil_thickness": soil_t, + "rock_kind": rock_kind, # 0 리핑암 · 1 발파암 + "q": np.where(road & in_band, road_q_kpa, 0.0), + "stations": sts, + } + + +def strata_from_grid( + grid: dict, + materials: list[dict] | None = None, + label_map: dict[str, str] | None = None, + *, + water_depth_m: float | None = None, + with_surcharge: bool = True, +) -> Strata: + """격자 → 기둥 솔버 지층(층 차례 = 머리 주석) · 물성은 id 로 고름(E03 꼴 · 빠지면 기본표). + + label_map — E02 층 이름 → 물성 id(soil · ripping_rock · blasting_rock · fill) · 빠지면 기본 짝. + + water_depth_m — 지하수위면 = 합성 지표 − 깊이(0 = 지표까지 포화 · KDS 우기 규칙) + · None = 지하수 없음. + """ + by_id = {m["id"]: m for m in DEFAULT_MATERIALS} + by_id.update({m["id"]: m for m in materials or []}) + z = grid["z"] + fill_bot = np.minimum(z, grid["z_orig"]) + soil_bot = np.minimum(grid["rock_top"], fill_bot) + blasting = grid["rock_kind"] == 1 + rip_bot = np.where(blasting, soil_bot, -1e6) + return Strata( + grid["x"], + grid["y"], + z, + np.stack([fill_bot, soil_bot, rip_bot]), + [by_id[(label_map or {}).get(lab, k)] for lab, k in zip(LAYER_LABELS, LAYER_MATERIALS)], + hard=list(LAYER_HARD), + z_water=None if water_depth_m is None else z - water_depth_m, + q=grid["q"] if with_surcharge else None, + ) + + +def section_check(grid: dict, dem_sampler, station: dict) -> dict: + """측점 횡단선 위 합성 DEM(쌍선형) − B06 설계선 · 원지형 − B06 지반선 — 오차 기록용.""" + d = np.asarray(station["design"], float) + c = np.asarray(station["center"], float) + left = np.asarray(station["left"], float) + left = left / np.hypot(*left) + pts = c[None] + d[:, :1] * left[None] + f = RegularGridInterpolator( + (grid["y"], grid["x"]), grid["z"], bounds_error=False, fill_value=np.nan + ) + z_syn = f(np.column_stack([pts[:, 1], pts[:, 0]])) + err = z_syn - d[:, 1] + g = np.asarray(station["ground"], float) + gp = c[None] + g[:, :1] * left[None] + z0, _ = dem_sampler(gp) + gerr = z0 - g[:, 1] + return { + "max_abs": float(np.nanmax(np.abs(err))), + "rms": float(np.sqrt(np.nanmean(err**2))), + "ground_max_abs": float(np.nanmax(np.abs(gerr))), + } diff --git a/E04_Stab3D/E04_Stab3D_Engine_Search.py b/E04_Stab3D/E04_Stab3D_Engine_Search.py new file mode 100644 index 000000000..c62c0fd7f --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_Engine_Search.py @@ -0,0 +1,679 @@ +"""E04 3D 활동면 탐색 — Scoops3D 방식 구 격자 + 타원체 · 셀별 최소 안전율 · 임계 체적. + +근거: Reid 외(2015) USGS TM 14-A1 3.2(탐색 격자 · 반지름 증분 · 부피/면적 제약 · +거친→고운 탐색) — 방식만 · 코드 자체. +다른 점(정하고 보고): +- 노드 높이 = 노드 아래 지표 + h — 고저차 큰 산지에서 땅속 · 너무 높은 노드를 덜어 냄. +- 타원체 = 활동 단면(d−z)은 원 · 회전축 방향 반축만 ratio 배(Hungr 1989 꼴) + — 수직력이 축을 지나 Bishop 그대로. 방향 = 노드 지표 낙하 방향(5×5 평활). + 구는 활동체 지표 평균 낙하 방향(Scoops3D 2.1). +- 한 구가 지표를 여러 덩이로 자르면 덩이마다 셈 — 셀 안 점 단위로 가름(_split_pieces · + 발끝을 살짝 비켜 지나는 구의 사면 몸통 · 발끝 앞 접시가 따로). +- 전역 최소 면은 마지막에 중심 · 반지름을 눈금 사이로 다듬음(_polish). +- 격자 가장자리 · DEM 빈칸에 닿은 덩이는 버림(잘린 질량 막음). +- 후보 덩이는 프로세스로 나눠 셈 · 낸 차례대로 합침 — 일꾼 수와 무관하게 같은 값. + +다스리는 안전율(governing): +- 못 뚫는 층(기본 발파암 = 연암)에 잘린 바닥 면적 비 ≥ 10 % → Janbu 보정으로 전환 + · 단 Bishop 이 더 낮으면 Bishop(작은 쪽). Hungr 1989 = 약층 복합면에서 Bishop 도 + 쓸 만함 · Zhang 1988 약층 경우 Janbu 보정 1.70 > Bishop 1.62 라 전환만은 덜 보수적. +- 10 % 문턱 = 기둥 몇 개 닿아 방법이 바뀌는 지도 이음매를 막음(Zhang 약층 잘림 비 33 %). +- 그 밖 → Hungr 3D Bishop · Bishop 이 수렴 못 하면 그 후보는 버림(Scoops3D 2.3.2). +""" + +from __future__ import annotations + +import math +import os +import pickle +import tempfile +import time +import weakref +from collections.abc import Callable +from concurrent.futures import ProcessPoolExecutor +from dataclasses import dataclass, field +from types import SimpleNamespace + +import numpy as np +from scipy import ndimage + +from E04_Stab3D.E04_Stab3D_Engine_Column import Columns, Strata, solve + +TRUNCATION_JANBU = 0.10 +_STRUCT = np.zeros((3, 3, 3), bool) +_STRUCT[1] = True # 같은 후보 안에서만 8 이웃 · 후보끼리는 안 이음 +BATCH_CELLS = 3_000_000 # 한 번에 만드는 (후보 × 창) 칸 수 — 메모리 약 0.5 GB 아래 + + +@dataclass +class SearchParams: + """탐색 조건 — 길이 m · 부피 m³ · 면적 m².""" + + box: tuple[float, float, float, float] | None = None # (x0, x1, y0, y1) · None = 격자 전체 + node_mask: np.ndarray | None = ( + None # (ny, nx) — 노드 아래 셀이 False 면 노드 뺌(노선 띠만 등 · Scoops3D 파일 탐색) + ) + spacing: float = 2.0 # 가장 고운 노드 수평 간격 + h_min: float = 2.0 # 노드 높이(지표 위) 범위 · 간격 + h_max: float = 30.0 + h_step: float = 2.0 + coarse: int = 4 # 거친 격자 배수(Scoops3D multres) + dr: float = 1.0 # 반지름 증분 + max_depth: float = 15.0 # 반지름 상한 = h + max_depth + v_min: float = 10.0 + v_max: float = 10_000.0 + a_min: float = 0.0 + a_max: float = math.inf + ratios: tuple[float, ...] = (1.0, 2.0) # 구(늘 봄) + 타원체 축 방향 배수(구 임계 노드에서) + sub: int = 2 # 셀 안 표본 sub×sub(부분 기둥) + workers: int = 0 # 프로세스 수 · 0 = 자동(격자 5만 칸 이상이면 CPU − 2 · 12 까지) + refine_rounds: int = 4 # 가장 고운 간격에서 더 내려가 보는 횟수 상한 + # 고운 탐색 · 타원체는 F < max(이 값, 전역 최소 × 1.2) 로 고친 노드만(높은 곳 지도는 거친 값) + refine_below: float = 2.0 + groundwater: dict | None = None + seismic_kh: float = 0.0 + # 화면 고리(533-3) — 결과 불변 · on_progress(percent, message) · should_stop() 참이면 InterruptedError + on_progress: Callable[[int, str], None] | None = None + should_stop: Callable[[], bool] | None = None + + +@dataclass +class SearchResult: + f_min: np.ndarray # 셀별 최소 안전율(다스리는 값) · 닿지 않은 셀 = nan + volume: np.ndarray # 그 임계 활동체 부피 + method: np.ndarray # 0 = Bishop · 1 = Janbu 보정 + best: dict # 전체 최소 활동체(중심 · 반지름 · 비 · 방향 · 방법별 F · 부피 · 셀) + trials: int = 0 + evaluated: int = 0 + seconds: float = 0.0 + stats: dict = field(default_factory=dict) + + +def trial_columns( + strata: Strata, + centers: np.ndarray, + r: float, + ratio: float = 1.0, + directions: np.ndarray | None = None, + v_min: float = 0.0, + v_max: float = math.inf, + a_min: float = 0.0, + a_max: float = math.inf, + sub: int = 2, +) -> tuple[Columns, np.ndarray, np.ndarray]: + """후보 T 개(같은 r · ratio)의 기둥 — (Columns, 덩이의 후보 번호, 후보별 가장 큰 덩이 부피). + + 부분 기둥 — 셀마다 sub×sub 점에서 활동면 · 지표(쌍선형)를 보고 걸친 비율 · 평균 깊이로 + 기둥을 만듦(Scoops3D 2.1 부분 기둥과 같은 뜻 · 가장자리 적분 오차 줄임). sub=1 = 셀 중심. + """ + centers = np.atleast_2d(np.asarray(centers, float)) + n_t = len(centers) + dx = strata.dx + ny, nx = strata.z_top.shape + w = int(math.ceil(r * max(ratio, 1.0) / dx)) + 1 + off = np.arange(-w, w + 1) + ix0 = np.rint((centers[:, 0] - strata.x[0]) / dx).astype(int) + iy0 = np.rint((centers[:, 1] - strata.y[0]) / dx).astype(int) + ixg = ix0[:, None, None] + off[None, None, :] + iyg = iy0[:, None, None] + off[None, :, None] + inb = (ixg >= 0) & (ixg < nx) & (iyg >= 0) & (iyg < ny) + nw = 2 * w + 1 + shape = (n_t, nw, nw) + d = None if ratio == 1.0 else np.asarray(directions, float) + k2 = 1.0 / (ratio * ratio) + + def remainder(t, xs, ys): + """r² − (중심에서 수평 거리 꼴)² — 0 보다 크면 활동면이 그 점 위를 지남.""" + dxp = xs - centers[t, 0] + dyp = ys - centers[t, 1] + if d is None: + return r * r - dxp * dxp - dyp * dyp + pp = dxp * d[t, 0] + dyp * d[t, 1] + qq = -dxp * d[t, 1] + dyp * d[t, 0] + return r * r - pp * pp - k2 * qq * qq + + def surface(t, xs, ys): + """후보 t 의 활동면 — (rem, z_s, ∂z/∂x, ∂z/∂y). 원소별(납작).""" + dxp = xs - centers[t, 0] + dyp = ys - centers[t, 1] + if d is None: + rem = r * r - dxp * dxp - dyp * dyp + root = np.sqrt(np.maximum(rem, 1e-12)) + return rem, centers[t, 2] - root, dxp / root, dyp / root + dxd, dyd = d[t, 0], d[t, 1] + pp = dxp * dxd + dyp * dyd + qq = -dxp * dyd + dyp * dxd # 축 방향 a = (−d_y, d_x) + rem = r * r - pp * pp - k2 * qq * qq + root = np.sqrt(np.maximum(rem, 1e-12)) + gx = (pp * dxd - k2 * qq * dyd) / root + gy = (pp * dyd + k2 * qq * dxd) / root + return rem, centers[t, 2] - root, gx, gy + + # 1) 셀 중심으로 거르기 — 걸친 셀과 그 이웃만 셀 안 표본을 봄(창 대부분은 허공 · 땅속 밖) + # 창 칸 번호 → (후보, 셀) — 큰 창 배열을 펴지 않고 셈 + def cell_of(flat): + t = flat // (nw * nw) + return t, iy0[t] + off[(flat // nw) % nw], ix0[t] + off[flat % nw] + + rem0 = remainder(np.arange(n_t)[:, None, None], strata.x[0] + ixg * dx, strata.y[0] + iyg * dx) + in0 = np.flatnonzero(inb & (rem0 > 0)) + t0, yy0, xx0 = cell_of(in0) + zs0 = centers[t0, 2] - np.sqrt(rem0.ravel()[in0]) + zb0 = np.maximum(zs0, strata.hard_top[yy0, xx0]) + act0 = np.zeros(shape, bool) + act0.ravel()[in0[zb0 < strata.z_top[yy0, xx0]]] = True + cand = act0 if sub == 1 else ndimage.binary_dilation(act0, structure=_STRUCT) & inb + ci = np.flatnonzero(cand) + big = np.zeros(n_t) + e = np.zeros(0, int) + empty = (Columns(e, e, e, np.zeros(0), np.zeros(0), np.zeros(0), np.zeros((0, 3))), e, big) + if not len(ci): + return empty + tt, cy, cx = cell_of(ci) + hard = strata.hard_top[cy, cx] + + # 2) 부분 기둥 — sub×sub 점 + fr = ((np.arange(sub) + 0.5) / sub - 0.5) if sub > 1 else np.zeros(1) + pts: dict[str, list] = {k: [] for k in ("c", "depth", "zs", "gx", "gy", "px", "py")} + slot = np.full((sub, sub, len(ci)), -1, np.int32) # (ka, kb, 셀) → 점 번호 + n_pt = 0 + for ka, fy in enumerate(fr): + for kb, fx in enumerate(fr): + xs = strata.x[0] + (cx + fx) * dx + ys = strata.y[0] + (cy + fy) * dx + rem, z_s, gx, gy = surface(tt, xs, ys) + zg = strata.ground_at(cy, cx, fy, fx) + z_b = np.maximum(z_s, hard) + k = np.flatnonzero((rem > 0) & (z_b < zg)) + pts["c"].append(k) + slot[ka, kb, k] = n_pt + np.arange(len(k)) + n_pt += len(k) + for key, val in ( + ("depth", zg - z_b), + ("zs", z_s), + ("gx", gx), + ("gy", gy), + ("px", xs), + ("py", ys), + ): + pts[key].append(val[k]) + pt = {key: np.concatenate(v) for key, v in pts.items()} + if not n_pt: + return empty + lab_pt, n_lab = _split_pieces( + strata, slot, ci, n_t * nw * nw, nw, tt, cy, cx, hard, sub, surface, n_pt + ) + # 기둥 = (셀, 덩이) 짝 — 두 덩이가 한 셀을 나누면 기둥 둘 + key, inv = np.unique(pt["c"] * n_lab + lab_pt, return_inverse=True) + cell_k, lab = key // n_lab, key % n_lab + 1 # 덩이 번호 1.. (0 = 없음) + n_act = np.bincount(inv, minlength=len(key)).astype(float) + acc = { + k: np.bincount(inv, weights=pt[k], minlength=len(key)) + for k in ("depth", "zs", "gx", "gy", "px", "py") + } + tt, cy, cx = tt[cell_k], cy[cell_k], cx[cell_k] + a_h = dx * dx + depth_cell = acc["depth"] / (sub * sub) + vol = np.bincount(lab, weights=depth_cell, minlength=n_lab + 1) * a_h + area = np.bincount(lab, weights=n_act / (sub * sub), minlength=n_lab + 1) * a_h + edge = np.bincount(lab, weights=strata.edge[cy, cx].astype(float), minlength=n_lab + 1) + trial_of = np.zeros(n_lab + 1, int) + trial_of[lab] = tt + np.maximum.at(big, trial_of[1:], vol[1:]) + ok = (vol >= v_min) & (vol <= v_max) & (area >= a_min) & (area <= a_max) & (edge == 0) + ok[0] = False + keep_lab = np.flatnonzero(ok) + remap = np.full(n_lab + 1, -1) + remap[keep_lab] = np.arange(len(keep_lab)) + sel = remap[lab] >= 0 + na_s = n_act[sel] + mean = {k: acc[k][sel] / na_s for k in ("zs", "gx", "gy", "px", "py")} + frac = na_s / (sub * sub) + trial = trial_of[keep_lab] + cols = Columns( + iy=cy[sel], + ix=cx[sel], + group=remap[lab][sel], + z_slip=mean["zs"], + gx=mean["gx"], + gy=mean["gy"], + center=centers[trial], + direction=None if d is None else d[trial], + px=mean["px"], + py=mean["py"], + frac=frac, + depth=depth_cell[sel] / frac, + ) + return cols, trial, big + + +_PART_OPTS = ("v_min", "v_max", "a_min", "a_max", "sub", "groundwater", "seismic_kh") +_PART: dict = {} # 일꾼 프로세스의 지층 · 조건 + + +def _part_init(strata: Strata | str, opts: dict) -> None: + if isinstance(strata, str): + with open(strata, "rb") as fh: + strata = pickle.load(fh) + _PART.update(strata=strata, **opts) + + +def _open_pool(strata: Strata, opts: dict, n_workers: int) -> ProcessPoolExecutor: + """일꾼 풀 — 지층은 임시 파일로 넘김(spawn 관으로 넘기면 일꾼이 차례로 뜸 · 12 개 6 s).""" + fd, path = tempfile.mkstemp(suffix=".e04.pkl") + with os.fdopen(fd, "wb") as fh: + pickle.dump(strata, fh, protocol=pickle.HIGHEST_PROTOCOL) + pool = ProcessPoolExecutor(n_workers, initializer=_part_init, initargs=(path, opts)) + weakref.finalize(pool, os.remove, path) # 멈춤 · 오류로 빠져나가도 지움 + return pool + + +def _part(job: tuple, k: dict | None = None) -> tuple: + """후보 덩이 하나 — 기둥 + 솔버. 셀 합치기에 쓰는 칸(iy · ix · group)만 돌려줌(옮길 양 줄임).""" + xyz, dirs, r, ratio = job + k = k or _PART + st = k["strata"] + cols, trial, big = trial_columns( + st, xyz, r, ratio, dirs, k["v_min"], k["v_max"], k["a_min"], k["a_max"], k["sub"] + ) + res = solve(st, cols, k["groundwater"], k["seismic_kh"]) if len(trial) else None + light = SimpleNamespace( + iy=cols.iy.astype(np.int32), ix=cols.ix.astype(np.int32), group=cols.group.astype(np.int32) + ) + return light, trial, big, res + + +def _split_pieces(strata, slot, ci, n_win, nw, tt, cy, cx, hard, sub, surface, n_pt): + """활동면이 지표를 자른 점들을 덩이로 — 이웃 점(4 방향)이 둘 다 땅속이고 사이도 땅속이면 이음. + + 한 셀 안에서도 덩이가 갈림(발끝을 살짝 비켜 지나는 구 = 사면 몸통 · 발끝 앞 접시 따로). + 지표는 셀 중심 사이 쌍선형(축 방향 직선) · 활동면은 아래로 볼록 → 두 점 사이 깊이는 + 지표 꺾임(셀 중심 줄)에서만 끝점보다 낮아짐 → 셀 중심 줄을 사이에 둔 짝만 그 줄에서 봄. + slot = (ka, kb, 셀) → 점 번호(−1 없음) · ci = 창 칸 번호 · n_win = 창 칸 수. + 반환 (점별 덩이 번호, 수). + """ + from scipy.sparse import coo_matrix + from scipy.sparse.csgraph import connected_components + + fr = ((np.arange(sub) + 0.5) / sub - 0.5) if sub > 1 else np.zeros(1) + rows, cols_ = [], [] + + def link(a, b): + both = (a >= 0) & (b >= 0) + rows.append(a[both]) + cols_.append(b[both]) + + # 셀 안 — 꺾임(f = 0)을 사이에 둔 짝은 꺾임 점에서 땅속인지 봄 + for k in range(sub - 1): + for m in range(sub): + for axis in ("x", "y"): + a = slot[m, k] if axis == "x" else slot[k, m] + b = slot[m, k + 1] if axis == "x" else slot[k + 1, m] + c = np.flatnonzero((a >= 0) & (b >= 0)) + if fr[k] < 0 < fr[k + 1]: + fy, fx = (fr[m], 0.0) if axis == "x" else (0.0, fr[m]) + xs = strata.x[0] + (cx[c] + fx) * strata.dx + ys = strata.y[0] + (cy[c] + fy) * strata.dx + rem, z_s, _, _ = surface(tt[c], xs, ys) + zg = strata.ground_at(cy[c], cx[c], fy, fx) + c = c[(rem > 0) & (np.maximum(z_s, hard[c]) < zg)] + rows.append(a[c]) + cols_.append(b[c]) + # 셀 사이 — 이웃 셀(창 안 · 줄 넘김 막음)의 맞닿은 점끼리 + cell_at = np.full(n_win + nw, -1, np.int32) # 창 칸 → 셀 번호(끝 넘침 칸 −1) + cell_at[ci] = np.arange(len(ci), dtype=np.int32) + for step, pos in ((1, ci % nw), (nw, (ci // nw) % nw)): + j = cell_at[ci + step] + nb = np.flatnonzero((j >= 0) & (pos < nw - 1)) + j = j[nb] + for m in range(sub): + if step == 1: + link(slot[m, sub - 1, nb], slot[m, 0, j]) + else: + link(slot[sub - 1, m, nb], slot[0, m, j]) + r_ = np.concatenate(rows) + c_ = np.concatenate(cols_) + graph = coo_matrix((np.ones(len(r_), np.int8), (r_, c_)), shape=(n_pt, n_pt)) + n_lab, lab = connected_components(graph, directed=False) + return lab, n_lab + + +def governing(res: dict) -> tuple[np.ndarray, np.ndarray]: + """다스리는 안전율 · 방법(0 Bishop · 1 Janbu 보정) — 머리 주석 규칙.""" + jc = np.where(np.isfinite(res["janbu_corrected"]), res["janbu_corrected"], np.inf) + use_j = (res["truncated_ratio"] >= TRUNCATION_JANBU) & (jc < res["bishop"]) + f = np.where(use_j, jc, res["bishop"]) + f = np.where(np.isfinite(res["bishop"]), f, np.nan) + return f, use_j.astype(np.int8) + + +def evaluate_surface( + strata: Strata, + center, + r: float, + ratio: float = 1.0, + direction=None, + groundwater: dict | None = None, + seismic_kh: float = 0.0, + sub: int = 2, +) -> dict: + """활동면 하나(구 · 타원체) — 가장 큰 덩이의 방법별 안전율 · 부피.""" + dirs = None if direction is None else np.asarray(direction, float)[None] + cols, _, _ = trial_columns(strata, np.asarray(center, float)[None], r, ratio, dirs, sub=sub) + if not len(cols.group): + raise ValueError("활동면이 지표를 자르지 않음") + res = solve(strata, cols, groundwater, seismic_kh) + k = int(np.argmax(res["volume"])) + out = {key: (v[k] if isinstance(v, np.ndarray) else v) for key, v in res.items()} + out["governing"], out["method"] = (float(x[k]) for x in governing(res)) + return out + + +def _local_fall(strata: Strata) -> tuple[np.ndarray, np.ndarray]: + zs = ndimage.uniform_filter(np.nan_to_num(strata.z_top, nan=float(np.nanmean(strata.z_top))), 5) + gy, gx = np.gradient(zs, strata.dx) + norm = np.hypot(gx, gy) + norm[norm == 0] = 1.0 + return -gx / norm, -gy / norm + + +def search(strata: Strata, params: SearchParams | None = None) -> SearchResult: + """거친 격자 → 고운 격자(Scoops3D 3.2.2) 로 셀별 최소 안전율 · 임계 체적.""" + p = params or SearchParams() + t0 = time.perf_counter() + ny, nx = strata.z_top.shape + x0, x1, y0, y1 = p.box or (strata.x[0], strata.x[-1], strata.y[0], strata.y[-1]) + nxs = int((x1 - x0) // p.spacing) + 1 + nys = int((y1 - y0) // p.spacing) + 1 + nhs = int(round((p.h_max - p.h_min) / p.h_step)) + 1 + fall_x, fall_y = _local_fall(strata) + + f_min = np.full((ny, nx), np.inf) + volume = np.full((ny, nx), np.nan) + method = np.full((ny, nx), -1, np.int8) + node_map = np.full((ny, nx), -1, np.int64) + best = {"governing": np.inf} + at_best: dict = {} # 전역 최소의 노드 번호 · 방향(다듬기용 · 결과에 안 냄) + seen: set[int] = set() + counters = {"trials": 0, "evaluated": 0} + stage = [0, 40, "거친 탐색"] # 진행률 — 시작 % · 폭 % · 이름 + + def tick(done: float) -> None: + if p.should_stop and p.should_stop(): + raise InterruptedError + if p.on_progress: + p.on_progress(int(stage[0] + stage[1] * done), str(stage[2])) + + def node_xyz(ids: np.ndarray) -> np.ndarray: + ix = ids % nxs + iy = (ids // nxs) % nys + ih = ids // (nxs * nys) + x = x0 + ix * p.spacing + y = y0 + iy * p.spacing + cx = np.clip(np.rint((x - strata.x[0]) / strata.dx).astype(int), 0, nx - 1) + cy = np.clip(np.rint((y - strata.y[0]) / strata.dx).astype(int), 0, ny - 1) + z = strata.z_top[cy, cx] + p.h_min + ih * p.h_step + return np.column_stack([x, y, z]), cy, cx, p.h_min + ih * p.h_step + + # 후보 덩이 = 프로세스로 나눠 셈(_part) · 결과는 낸 차례대로 합침 → 일꾼 수와 무관한 같은 값 + n_workers = p.workers or ( + min(12, max(1, (os.cpu_count() or 2) - 2)) if ny * nx >= 50_000 else 1 + ) + opts = {k: getattr(p, k) for k in _PART_OPTS} + pool = _open_pool(strata, opts, n_workers) if n_workers > 1 else None + local = {"strata": strata, **opts} + + def run(ids: np.ndarray, ratios: tuple[float, ...] = (1.0,)) -> set[int]: + """노드들의 모든 반지름 · 비 — 셀 최소를 고친 노드 번호를 돌려줌.""" + improved: set[int] = set() + xyz0, cy0, cx0, _ = node_xyz(ids) + keep = np.isfinite(xyz0[:, 2]) + if p.node_mask is not None: + keep &= p.node_mask[cy0, cx0] + ids = ids[keep] + if not len(ids): + return improved + xyz, cy, cx, h = node_xyz(ids) + dirs = np.column_stack([fall_x[cy, cx], fall_y[cy, cx]]) + r_lo = np.maximum(p.dr, np.floor(0.5 * h / p.dr) * p.dr) + r_hi = h + p.max_depth + for k_ratio, ratio in enumerate(ratios): + done = np.zeros(len(ids), bool) + radii = np.arange(r_lo.min(), r_hi.max() + 1e-9, p.dr) + for k_r, r in enumerate(radii): + tick((k_ratio + k_r / len(radii)) / len(ratios)) + live = np.flatnonzero(~done & (r_lo <= r + 1e-9) & (r <= r_hi + 1e-9)) + if not len(live): + continue + w_now = (2 * math.ceil(r * max(ratio, 1) / strata.dx) + 3) ** 2 + # 메모리 = 일꾼 수 합쳐 BATCH_CELLS · 일꾼마다 한 덩이 이상(작은 고운 탐색도 나눔) + chunk = max(1, BATCH_CELLS // (w_now * n_workers)) + chunk = min(chunk, -(-len(live) // n_workers)) + parts = [live[s : s + chunk] for s in range(0, len(live), chunk)] + jobs = [(xyz[q], dirs[q] if ratio != 1.0 else None, r, ratio) for q in parts] + outs = pool.map(_part, jobs) if pool else (_part(j, local) for j in jobs) + # 고친 노드 판정 기준 = 이 반지름 시작 값(덩이 나눔과 무관) + ref = f_min.copy() + thr = max(p.refine_below, 1.2 * best["governing"]) + for part, (cols, trial, big, res) in zip(parts, outs): + done[part[big > p.v_max]] = True + counters["trials"] += len(part) + if not len(trial): + continue + counters["evaluated"] += len(trial) + f, meth = governing(res) + improved |= _update( + f, + meth, + res, + cols, + trial, + ids[part], + f_min, + volume, + method, + node_map, + thr, + ref, + ) + k = _set_best(best, res, f, meth, xyz[part][trial], r, ratio, cols, nx) + if k >= 0: + at_best.update( + node=int(ids[part][trial[k]]), + direction=dirs[part][trial[k]] if ratio != 1.0 else None, + ) + return improved + + try: + step = max(1, int(p.coarse)) + grid = np.stack( + np.meshgrid( + np.arange(0, nhs, step), + np.arange(0, nys, step), + np.arange(0, nxs, step), + indexing="ij", + ), + -1, + ).reshape(-1, 3) + ids = (grid[:, 0] * nys + grid[:, 1]) * nxs + grid[:, 2] + seen.update(ids.tolist()) + seeds = run(ids) + rounds = passes = 0 + while seeds and (step > 1 or rounds < p.refine_rounds): + if step == 1: + rounds += 1 + step = max(1, step // 2) + passes += 1 + stage[:] = [min(40 + 8 * (passes - 1), 77), 8, f"고운 탐색 {passes}"] + cand = set() + for nid in seeds: + ih, rem = divmod(nid, nys * nxs) + iy, ix = divmod(rem, nxs) + for dh in (-step, 0, step): + for dy in (-step, 0, step): + for dxs in (-step, 0, step): + h2, y2, x2 = ih + dh, iy + dy, ix + dxs + if 0 <= h2 < nhs and 0 <= y2 < nys and 0 <= x2 < nxs: + cid = (h2 * nys + y2) * nxs + x2 + if cid not in seen: + cand.add(cid) + if not cand: + if step == 1: + break + continue + seen.update(cand) + seeds = run(np.fromiter(cand, np.int64)) + + # 타원체 — 구 탐색이 찾은 임계 노드(F < refine_below)에서만(시간 1/3 · 노선 1 km 실측) + ell = tuple(r for r in p.ratios if r != 1.0) + limit = max(p.refine_below, 1.2 * best["governing"]) + crit = np.unique(node_map[(node_map >= 0) & (f_min < limit)]) + if ell and len(crit): + stage[:] = [85, 14, "타원체"] + run(crit, ell) + + if at_best: + stage[:] = [99, 1, "다듬기"] + tick(0.0) + _polish(strata, p, best, at_best, f_min, volume, method, node_map) + + finally: + if pool: # 끊기(InterruptedError) · 오류에도 일꾼을 닫음 + pool.shutdown(cancel_futures=True) + f_out = np.where(np.isfinite(f_min), f_min, np.nan) + return SearchResult( + f_min=f_out, + volume=volume, + method=method, + best=best, + trials=counters["trials"], + evaluated=counters["evaluated"], + seconds=time.perf_counter() - t0, + stats={"nodes": len(seen), "cells": int(np.isfinite(f_out).sum())}, + ) + + +def _set_best( + best: dict, res: dict, f, meth, centers, r: float, ratio: float, cols, nx: int +) -> int: + """덩이들 가운데 best 보다 낮은 것이 있으면 best 를 바꾸고 그 번호 · 없으면 −1.""" + if not np.isfinite(f).any(): + return -1 + k = int(np.nanargmin(np.where(np.isfinite(f), f, np.inf))) + if not f[k] < best["governing"]: + return -1 + best.clear() + best.update( + {key: (v[k].tolist() if isinstance(v, np.ndarray) else v) for key, v in res.items()} + ) + best.update( + governing=float(f[k]), + method=int(meth[k]), + center=np.asarray(centers[k], float).tolist(), + radius=float(r), + ratio=float(ratio), + # 활동체 셀(iy·nx+ix) — 한 구가 자른 여러 덩이 중 이 덩이만 + cells=np.unique(cols.iy[cols.group == k] * nx + cols.ix[cols.group == k]).tolist(), + ) + return k + + +def _polish(strata, p, best, at_best, f_min, volume, method, node_map) -> None: + """전역 최소 다듬기 — 그 면의 중심 · 반지름을 노드 · 반지름 눈금 사이로 옮겨 봄(Nelder-Mead). + + 급경사 전역 최소는 발끝에 거의 닿는 면 — 눈금(노드 간격 · dr)에 걸린 값이 연속 최소보다 + 1~2 % 높음(60° 제방 1 m 격자 0.7595 → 0.7519 · Scoops3D 0.75). 비 · 방향은 그대로. + """ + from scipy.optimize import minimize + + ratio = best["ratio"] + d = at_best["direction"] + d = None if d is None else np.asarray(d, float)[None] + + def trial(v): + cols, tr, _ = trial_columns( + strata, v[None, :3], v[3], ratio, d, p.v_min, p.v_max, p.a_min, p.a_max, p.sub + ) + if not len(tr): + return None + res = solve(strata, cols, p.groundwater, p.seismic_kh) + return (cols, tr, res, *governing(res)) + + def objective(v): + if p.node_mask is not None: # 둘레 네 셀이 모두 노드 칸이어야(가장자리 밖으로 안 샘) + fy = (v[1] - strata.y[0]) / strata.dx + fx = (v[0] - strata.x[0]) / strata.dx + iy = np.clip([math.floor(fy), math.ceil(fy)], 0, len(strata.y) - 1) + ix = np.clip([math.floor(fx), math.ceil(fx)], 0, len(strata.x) - 1) + if not p.node_mask[np.ix_(iy, ix)].all(): + return np.inf + out = trial(v) + if out is None or not np.isfinite(out[3]).any(): + return np.inf + return float(np.nanmin(out[3])) + + v0 = np.r_[best["center"], best["radius"]] + step = np.array([p.spacing, p.spacing, p.h_step, p.dr]) / 2 + simplex = np.vstack([v0, v0 + np.diag(step)]) + # 찾은 노드 · 반지름에서 한 눈금 안(그 사이를 놓친 것만 고침) · box 안 + lo, hi = v0 - 2 * step, v0 + 2 * step + if p.box is not None: + lo[:2] = np.maximum(lo[:2], (p.box[0], p.box[2])) + hi[:2] = np.minimum(hi[:2], (p.box[1], p.box[3])) + o = minimize( + objective, + v0, + method="Nelder-Mead", + bounds=list(zip(lo, hi)), + options={"initial_simplex": simplex, "xatol": 1e-3, "fatol": 1e-5, "maxiter": 400}, + ) + if not o.fun < best["governing"] * (1 - 1e-6): + return + cols, tr, res, f, meth = trial(o.x) + _update(f, meth, res, cols, tr, np.array([at_best["node"]]), f_min, volume, method, node_map) + _set_best( + best, + res, + f, + meth, + np.repeat(o.x[None, :3], len(tr), 0), + float(o.x[3]), + ratio, + cols, + strata.z_top.shape[1], + ) + + +def _update( + f, + meth, + res, + cols, + trial, + node_ids, + f_min, + volume, + method, + node_map, + refine_below=np.inf, + ref=None, +) -> set[int]: + """셀마다 더 낮은 F 면 바꿈 — 같은 셀 여럿이면 가장 낮은 것(내림차순으로 써 마지막이 이김). + + 돌려주는 노드 = ref(없으면 f_min) 보다 낮춘 후보의 노드 중 F < refine_below. + """ + fc = f[cols.group] + ok = np.isfinite(fc) + iy, ix, grp, fc = cols.iy[ok], cols.ix[ok], cols.group[ok], fc[ok] + order = np.argsort(-fc, kind="stable") + iy, ix, grp, fc = iy[order], ix[order], grp[order], fc[order] + ref = f_min if ref is None else ref + hit = (fc < ref[iy, ix] * (1 - 1e-6)) & (fc < refine_below) + seeds = set(np.unique(node_ids[trial[grp[hit]]]).tolist()) + better = fc < f_min[iy, ix] * (1 - 1e-6) + iy, ix, grp, fc = iy[better], ix[better], grp[better], fc[better] + f_min[iy, ix] = fc + volume[iy, ix] = res["volume"][grp] + method[iy, ix] = meth[grp] + node_map[iy, ix] = node_ids[trial[grp]] + return seeds diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py new file mode 100644 index 000000000..2416a95e8 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -0,0 +1,238 @@ +"""E04 안정성평가 3D 해석 API (요구 347-1 · 351 · 요구서 4-2 · 4-3 · PLAN 533-3). + +- `POST /api/projects/{id}/stab/analyze3d` — 한 구간 3D 해석(뒤에서) · 입력 지문이 저장본과 같으면 + 셈하지 않고 `cached` · `force` 면 다시 셈 +- `POST …/stab/analyze3d/cancel` — 도는 해석 끊기(다시 시작 = analyze3d 다시) +- `GET …/stab/progress3d` — 진행률 +- `GET …/stab/result3d` — 자동 구간 · 노선 · 측점 · 저장본 요약(+ 지문이 지금과 다른지) +- `GET …/stab/result3d?key=` — 한 구간 저장본(래스터 · 임계 활동면) + +계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company`(지정받은 회사 = GET 만 · 그 밖 403). +""" + +from __future__ import annotations + +import asyncio +import logging +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from common_util.common_util_auth import require_company +from common_util.common_util_storage import resolve_stored_project_path +from config.config_db import get_db_pool +from E01_StabRegister import E01_StabRegister_Source as source +from E04_Stab3D import E04_Stab3D_Run as run + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["E04 Stab 3D"]) + +# 진행 — 프로젝트 → {running, percent, message, error, cancelled, key, summary} · 프로세스 메모리 +_JOBS: dict[str, dict[str, Any]] = {} +_STOPS: dict[str, bool] = {} +_TASKS: set[asyncio.Task] = set() +_IDLE = { + "running": False, + "percent": 0, + "message": None, + "error": None, + "cancelled": False, + "key": None, + "summary": None, +} + + +class SearchSettings(BaseModel): + """탐색 조건 — 길이 m · 부피 m³ (Scoops3D 방식 · 요구서 4-2).""" + + shape: Literal["sphere", "both"] = "both" # both = 구 + 구 임계 노드에서 타원체 + ellipsoid_ratio: float = Field(2.0, ge=1.1, le=5.0) # 타원체 = 회전축 방향 반축 배수 + box_half_width_m: float = Field(30.0, ge=5.0, le=100.0) # 탐색 상자 = 중심선 ± 이 폭 + cell_m: float = Field(1.0, ge=0.5, le=5.0) # 기둥(셀) 크기 + spacing_m: float = Field(2.0, ge=0.5, le=10.0) # 구 중심 수평 간격(가장 고운) + h_min_m: float = Field(2.0, ge=0.0, le=100.0) # 구 중심 높이(지표 위) 범위 · 간격 + h_max_m: float = Field(30.0, ge=1.0, le=200.0) + h_step_m: float = Field(2.0, ge=0.5, le=20.0) + dr_m: float = Field(1.0, ge=0.2, le=5.0) # 반지름 증분 + max_depth_m: float = Field(15.0, ge=1.0, le=60.0) + v_min_m3: float = Field(10.0, ge=0.0) + v_max_m3: float = Field(10_000.0, gt=0.0) + + +class Analyze3dRequest(BaseModel): + start_m: float = Field(..., ge=0.0) + end_m: float = Field(..., ge=0.0) + search: SearchSettings = SearchSettings() + condition: Literal["dry", "wet"] = "dry" # 우기 = 지표까지 포화(KDS 우기 규칙) + force: bool = False + + +async def _project_root(project_id: UUID) -> Path: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT storage_path FROM projects" + " WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL", + (str(project_id),), + ) + row = await cursor.fetchone() + if not row or not row["storage_path"]: + raise HTTPException(status_code=404, detail="안정성평가 프로젝트가 아닙니다.") + return Path(resolve_stored_project_path(row["storage_path"])) + + +def _source_root(project_root: Path) -> Path: + root = source.source_dir(project_root) + if not (root / source.MANIFEST_NAME).is_file(): + raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.") + return root + + +def _settings(payload: Analyze3dRequest) -> dict[str, Any]: + """지문 · 저장본에 남는 요청 — 소수 둘째 자리로 맞춤(같은 구간 = 같은 열쇠).""" + if payload.end_m <= payload.start_m: + raise HTTPException(status_code=400, detail="구간 끝이 시작보다 앞입니다.") + if payload.end_m - payload.start_m > run.MAX_SECTION_M: + raise HTTPException( + status_code=400, detail=f"구간은 {run.MAX_SECTION_M:.0f} m 이하로 고르세요." + ) + search = payload.search + if search.h_max_m < search.h_min_m or search.v_max_m3 <= search.v_min_m3: + raise HTTPException(status_code=400, detail="탐색 범위의 최소가 최대보다 큽니다.") + return { + "start_m": round(payload.start_m, 2), + "end_m": round(payload.end_m, 2), + "condition": payload.condition, + **search.model_dump(), + } + + +def _current_fingerprint(project_root: Path, root: Path, settings: dict[str, Any]) -> str: + return run.fingerprint( + source.manifest(root)["source_fingerprint"], run.read_ground(project_root), settings + ) + + +@router.post("/{project_id}/stab/analyze3d", response_model=None) +async def analyze3d(project_id: UUID, payload: Analyze3dRequest) -> dict[str, Any]: + """[해석] — 지문이 같으면 `cached`(안 셈) · 다르거나 force 면 뒤에서 셈.""" + pid = str(project_id) + project_root = await _project_root(project_id) + root = _source_root(project_root) + settings = _settings(payload) + key = run.section_key(settings["start_m"], settings["end_m"]) + finger = await asyncio.to_thread(_current_fingerprint, project_root, root, settings) + saved = await asyncio.to_thread(run.load, project_root, key) + if not payload.force and saved and saved["fingerprint"] == finger: + return {"status": "cached", "key": key, "summary": saved["summary"]} + if (_JOBS.get(pid) or {}).get("running"): + raise HTTPException(status_code=409, detail="이미 3D 해석이 돌고 있습니다.") + + job = {**_IDLE, "running": True, "message": "시작", "key": key} + _JOBS[pid] = job + _STOPS[pid] = False + + def report(percent: int, message: str) -> None: + job.update(percent=max(0, min(99, int(percent))), message=message) + + async def work() -> None: + try: + job["summary"] = await asyncio.to_thread( + run.analyze, project_root, root, settings, finger, report, lambda: _STOPS[pid] + ) + job.update(percent=100, message="끝") + except InterruptedError: + job.update(cancelled=True, message="끊음") + except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림 + logger.exception("E04 3D 해석 실패: project_id=%s", pid) + job["error"] = str(getattr(error, "detail", None) or error) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E04 3D {pid}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return {"status": "accepted", "key": key} + + +@router.post("/{project_id}/stab/analyze3d/cancel", response_model=None) +async def cancel3d(project_id: UUID) -> dict[str, Any]: + """[끊기] — 엔진이 다음 확인 때 멈춤 · 저장본은 그대로.""" + pid = str(project_id) + running = bool((_JOBS.get(pid) or {}).get("running")) + if running: + _STOPS[pid] = True + return {"status": "success", "stopping": running} + + +@router.get("/{project_id}/stab/progress3d", response_model=None) +async def progress3d(project_id: UUID) -> dict[str, Any]: + return {"status": "success", **dict(_JOBS.get(str(project_id)) or _IDLE)} + + +def _overview(project_root: Path, root: Path) -> dict[str, Any]: + stations = source.stations(root) + finger_source = source.manifest(root)["source_fingerprint"] + ground = run.read_ground(project_root) + saved = run.saved_summaries(project_root) + for item in saved: + current = run.fingerprint(finger_source, ground, item.pop("settings")) + item["stale"] = item.pop("fingerprint") != current + return { + "sections": run.auto_sections(stations), + "stations": [ + { + "chainage_m": s["chainage_m"], + "label": s["label"], + "x": s["center_x"], + "y": s["center_y"], + } + for s in stations + ], + # 중심선 = 측점 중심(B06 누가 그대로 · route_points 는 접선 꺾은선이라 누가가 다름) + "line": [ + [float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])] + for s in sorted(stations, key=lambda s: float(s["chainage_m"])) + ], + "saved": saved, + "defaults": SearchSettings().model_dump(), + "max_section_m": run.MAX_SECTION_M, + } + + +async def _editable(project_id: UUID, session: dict[str, Any]) -> bool: + """[해석] 가능 = 시스템 관리자 · 같은 회사(지정받은 회사는 보기만).""" + if session.get("role") == "SYSTEM_ADMIN": + return True + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT company_id FROM projects WHERE id = %s", (str(project_id),)) + row = await cursor.fetchone() + return bool(row) and row[0] == session.get("company_id") + + +@router.get("/{project_id}/stab/result3d", response_model=None) +async def result3d( + project_id: UUID, + key: str | None = None, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """key 없음 = 구간 · 노선 · 저장본 요약 / key = 그 구간 래스터 · 임계 활동면.""" + project_root = await _project_root(project_id) + root = _source_root(project_root) + if key is None: + overview = await asyncio.to_thread(_overview, project_root, root) + editable = await _editable(project_id, session) + return {"status": "success", "editable": editable, **overview} + if not all(ch.isdigit() or ch in "._" for ch in key): + raise HTTPException(status_code=400, detail="구간 열쇠가 틀렸습니다.") + doc = await asyncio.to_thread(run.load, project_root, key) + if doc is None: + raise HTTPException(status_code=404, detail="이 구간 3D 해석 결과가 없습니다.") + current = await asyncio.to_thread(_current_fingerprint, project_root, root, doc["settings"]) + return {"status": "success", "key": key, "stale": doc["fingerprint"] != current, **doc} diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py new file mode 100644 index 000000000..fcd600263 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -0,0 +1,389 @@ +"""E04 3D 해석 한 번 — 입력 모으기 · 입력 지문 · 엔진 부르기 · 파생 저장본 (PLAN 533-3). + +계산 자리 = ③ 서버 단독. 엔진(탐색 · 기둥 솔버 · 합성 지형) 주인 = 533-1 · 2 — 여기는 부르기만. +저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 입력 지문(복사본 지문 · E02 지반 · +요청 · 엔진 판)이 같으면 다시 셈하지 않음. + +래스터 꼴(화면 `logic/E04_Stab3D_Raster.ts` 와 같은 열쇠): + grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열 + z = 합성 지표(원지형 + 노선면) · f = 셀별 최소 안전율 · v = 그 임계 체적 m³ · m = 방법(0 Bishop + · 1 Janbu 보정) · 닿지 않은 셀 = null + best = 전체 최소 활동면 {x, y, z(중심), r, ratio, azimuth_deg(활동 방향), f, volume, method, ix, iy, + hovland · bishop · janbu_corrected(같은 면 방법별 F) · truncated_ratio · floor([셀, 못 뚫는 층 윗면]) + · cells(활동체 덩이 셀 번호 — 한 구가 자른 여러 덩이 중 이 덩이)} + 타원체 = 회전축(활동 방향에 수직) 반축만 ratio·r — 533-2 `trial_columns` 와 같은 꼴. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import time +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any + +import numpy as np +from scipy.spatial import cKDTree + +from E01_StabRegister import E01_StabRegister_Source as source +from E02_StabGround import E02_StabGround_Model as ground_model +from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA +from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine +from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine + +ENGINE_VERSION = "e04-4" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +STAGE_DIR = "E04_Stab3D" +SECTION_LENGTH_M = 100.0 # 자동 구간 길이 +MAX_SECTION_M = 500.0 # 한 번에 셀 구간 상한(시간 · 메모리) +METHOD_NAMES = ("bishop", "janbu_corrected") + +Progress = Callable[[int, str], None] +StopFlag = Callable[[], bool] + + +def results_dir(project_root: Path) -> Path: + return Path(project_root) / STAGE_DIR / "results" + + +def section_key(start_m: float, end_m: float) -> str: + return f"{start_m:.2f}_{end_m:.2f}" + + +def auto_sections(stations: list[dict[str, Any]]) -> list[dict[str, float]]: + """측점을 앞에서부터 SECTION_LENGTH_M 이상 묶음 — 경계 = 측점 · 꼬리가 짧으면 앞 구간에 붙임.""" + chainages = sorted({float(s["chainage_m"]) for s in stations}) + out: list[dict[str, float]] = [] + start = chainages[0] if chainages else 0.0 + for ch in chainages[1:]: + if ch - start >= SECTION_LENGTH_M - 1e-6: + out.append({"start_m": start, "end_m": ch}) + start = ch + if chainages and start < chainages[-1]: + if out and chainages[-1] - start < SECTION_LENGTH_M / 2: + out[-1]["end_m"] = chainages[-1] + else: + out.append({"start_m": start, "end_m": chainages[-1]}) + return out + + +def read_ground(project_root: Path) -> dict[str, Any]: + """E02 작업본 → 없으면 초기값 → 없으면 마스터 기본값(E05 와 같은 차례).""" + for path in (ground_model.ground_path(project_root), ground_model.initial_path(project_root)): + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + return ground_model.pick(ground_model.load_defaults()) + + +def fingerprint( + source_fingerprint: str, ground: dict[str, Any] | None, settings: dict[str, Any] +) -> str: + body = json.dumps( + { + "source": source_fingerprint, + "ground": ground, + "settings": settings, + "engine": ENGINE_VERSION, + }, + ensure_ascii=False, + sort_keys=True, + ) + return hashlib.sha1(body.encode("utf-8")).hexdigest() + + +def section_stations(stations: list[dict[str, Any]], start_m: float, end_m: float) -> list[dict]: + return [s for s in stations if start_m - 1e-6 <= float(s["chainage_m"]) <= end_m + 1e-6] + + +def bbox_of( + stations: list[dict[str, Any]], half_width_m: float +) -> tuple[float, float, float, float]: + xs = [float(s["center_x"]) for s in stations] + ys = [float(s["center_y"]) for s in stations] + return ( + min(xs) - half_width_m, + max(xs) + half_width_m, + min(ys) - half_width_m, + max(ys) + half_width_m, + ) + + +# --------------------------------------------------------------------------- +# 엔진 — 합성 DEM · 지층(533-1 Grid) → 구 · 타원체 탐색 + 기둥 솔버(533-2 Search · Column) +# --------------------------------------------------------------------------- + + +def _engine_station(root: Path, station: dict[str, Any]) -> dict[str, Any]: + """복사본 측점 → 533-1 측점 한 줄(중심 · 좌표틀 · 지반선 · B06 설계선 · 암 깊이 · 노면).""" + cross = source.cross_section(root, station["chainage_m"]) + row = { + "chainage_m": station["chainage_m"], + "center_x": station["center_x"], + "center_y": station["center_y"], + "frame": cross["frame"], + "samples": cross["samples"], + } + return grid_engine.station_from_b06(row, cross["design"]) + + +def _node_mask( + stations: list[dict[str, Any]], + grid: dict[str, Any], + start_m: float, + end_m: float, + half_width_m: float, +) -> np.ndarray: + """탐색 노드 자리 = 중심선에서 half_width 안 · 가장 가까운 중심선 점의 누가가 구간 안 + · 가장 가까운 점이 중심선 양 끝점인 셀(시점 앞 · 종점 뒤)은 뺌. + + 중심선 = 측점 중심을 이은 선(B06 누가 그대로 · route_points 는 접선 꺾은선이라 누가가 다름).""" + line = np.array( + [[float(s["center_x"]), float(s["center_y"]), float(s["chainage_m"])] for s in stations] + ) + # 0.5 m 로 촘촘히 — 꺾인 점 사이 셀도 바른 누가 + dense = [line[:1]] + for a, b in zip(line[:-1], line[1:]): + n = max(1, int(math.ceil(math.hypot(*(b[:2] - a[:2])) / 0.5))) + t = np.linspace(0, 1, n + 1)[1:, None] + dense.append(a + (b - a) * t) + line = np.vstack(dense) + xx, yy = np.meshgrid(grid["x"], grid["y"]) + dist, at = cKDTree(line[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()])) + chain = line[at, 2] + keep = (dist <= half_width_m) & (chain >= start_m - 1e-6) & (chain <= end_m + 1e-6) + keep &= (at > 0) & (at < len(line) - 1) + return keep.reshape(xx.shape) + + +def _floor(strata: Any, best: dict[str, Any]) -> list[list[float]]: + """임계면이 못 뚫는 층에 잘린 셀 — [셀 번호, 층 윗면] (화면 활동면 메쉬가 그 높이로 자름).""" + xx, yy = np.meshgrid(strata.x, strata.y) + cx, cy, cz = best["center"] + d = np.asarray(best.get("direction") or [0.0, 1.0], float) + pp = (xx - cx) * d[0] + (yy - cy) * d[1] + qq = (-(xx - cx) * d[1] + (yy - cy) * d[0]) / max(float(best["ratio"]), 1e-9) + rest = float(best["radius"]) ** 2 - pp**2 - qq**2 + slip = np.where(rest >= 0, cz - np.sqrt(np.maximum(rest, 0)), np.nan) + hard = strata.hard_top + hit = np.isfinite(slip) & (slip < strata.z_top) & (hard > slip) & (hard < strata.z_top) + if "cells" in best: # 활동체 덩이 셀만 + hit &= np.isin(np.arange(hit.size), best["cells"]).reshape(hit.shape) + return [[int(i), round(float(hard.ravel()[i]), 2)] for i in np.flatnonzero(hit.ravel())] + + +def reference_fs(ground: dict[str, Any] | None, condition: str) -> float: + """기준안전율(E02 · 없으면 마스터 기본) — 깎기 · 쌓기 중 큰 값(보수).""" + factors = (ground or ground_model.load_defaults()).get("safety_factors") or {} + row = factors.get(condition) or {} + return float(max(row.get("cut", 1.5), row.get("fill", 1.5))) + + +def run_engine( + root: Path, + stations: list[dict[str, Any]], + ground: dict[str, Any] | None, + settings: dict[str, Any], + on_progress: Progress, + should_stop: StopFlag, +) -> dict[str, Any]: + """엔진 한 번 → {grid, z, f, v, m(넘파이 (ny, nx)), best, trials}. + + stations = 구간 측점 + 앞뒤 이웃 하나씩(구간 끝 합성 지형이 이어지게). + """ + on_progress(1, "합성 지형") + half = float(settings["box_half_width_m"]) + margin = half + float(settings["max_depth_m"]) + 10.0 # 활동체가 격자 끝에 닿지 않게 + with np.load(source.terrain_grid_path(root)) as npz: + dem = {k: npz[k] for k in ("x", "y", "z", "valid_mask")} + grid = grid_engine.build_grid( + dem, + [_engine_station(root, s) for s in stations], + bbox=bbox_of(stations, margin), + road_q_kpa=float((ground or {}).get("road_surcharge_kpa", DEFAULT_ROAD_SURCHARGE_KPA)), + cell_m=float(settings["cell_m"]), + ) + wet = settings["condition"] == "wet" + strata = grid_engine.strata_from_grid( + grid, + (ground or {}).get("materials"), + (ground or {}).get("label_map"), + water_depth_m=0.0 if wet else None, + ) + mask = _node_mask(stations, grid, settings["start_m"], settings["end_m"], half) + ratios = (1.0,) if settings["shape"] == "sphere" else (1.0, float(settings["ellipsoid_ratio"])) + params = search_engine.SearchParams( + node_mask=mask, + spacing=float(settings["spacing_m"]), + h_min=float(settings["h_min_m"]), + h_max=float(settings["h_max_m"]), + h_step=float(settings["h_step_m"]), + dr=float(settings["dr_m"]), + max_depth=float(settings["max_depth_m"]), + v_min=float(settings["v_min_m3"]), + v_max=float(settings["v_max_m3"]), + ratios=ratios, + groundwater={"mode": "line"} if wet else None, # 지하수위면 = strata.z_water + on_progress=lambda pct, msg: on_progress(2 + int(pct * 0.97), msg), + should_stop=should_stop, + ) + res = search_engine.search(strata, params) + best = res.best + if not math.isfinite(float(best.get("governing", math.inf))): + raise ValueError("조건에 맞는 활동면이 없습니다 — 부피 · 높이 범위를 넓히세요.") + d = best.get("direction") or [0.0, 1.0] + cx, cy, cz = best["center"] + return { + "grid": { + "x0": float(strata.x[0]), + "y0": float(strata.y[0]), + "dx": float(strata.dx), + "nx": len(strata.x), + "ny": len(strata.y), + }, + "z": strata.z_top, + "f": res.f_min, + "v": res.volume, + "m": np.where(res.method >= 0, res.method, np.nan), + "best": { + "x": float(cx), + "y": float(cy), + "z": float(cz), + "r": float(best["radius"]), + "ratio": float(best["ratio"]), + "azimuth_deg": math.degrees(math.atan2(float(d[0]), float(d[1]))) % 360.0, + "f": float(best["governing"]), + "volume": float(best["volume"]), + "method": int(best["method"]), + "hovland": float(best["hovland"]), + "bishop": float(best["bishop"]), + "janbu_corrected": float(best["janbu_corrected"]), + "truncated_ratio": float(best["truncated_ratio"]), + "floor": _floor(strata, best), + "cells": [int(i) for i in best["cells"]], + }, + "trials": int(res.trials), + "reference_fs": reference_fs(ground, settings["condition"]), + } + + +# --------------------------------------------------------------------------- +# 저장본 +# --------------------------------------------------------------------------- + + +def _flat(values: np.ndarray, digits: int) -> list[float | None]: + return [None if not math.isfinite(v) else round(float(v), digits) for v in values.ravel()] + + +def _nearest_chainage(stations: list[dict[str, Any]], x: float, y: float) -> float: + return min( + stations, key=lambda s: (float(s["center_x"]) - x) ** 2 + (float(s["center_y"]) - y) ** 2 + )["chainage_m"] + + +def pack( + raw: dict[str, Any], + stations: list[dict[str, Any]], + settings: dict[str, Any], + finger: str, + elapsed_s: float, +) -> dict[str, Any]: + """엔진 결과 → 저장본(JSON) — 요약은 best 에서.""" + grid = raw["grid"] + best = dict(raw["best"]) + best["ix"] = int(round((best["x"] - grid["x0"]) / grid["dx"])) + best["iy"] = int(round((best["y"] - grid["y0"]) / grid["dx"])) + f = np.asarray(raw["f"], dtype=float) + cells = np.asarray(best.get("cells") or [], int) + # 가까운 측점 = 활동체 평면 중심(없으면 구 중심) — 화면 최소 표시와 같은 자리 + where = ( + ( + grid["x0"] + (cells % grid["nx"]).mean() * grid["dx"], + grid["y0"] + (cells // grid["nx"]).mean() * grid["dx"], + ) + if len(cells) + else (best["x"], best["y"]) + ) + return { + "fingerprint": finger, + "engine": ENGINE_VERSION, + "settings": settings, + "computed_at": datetime.now().isoformat(timespec="seconds"), + "elapsed_s": round(elapsed_s, 2), + "grid": grid, + "z": _flat(np.asarray(raw["z"], dtype=float), 2), + "f": _flat(f, 3), + "v": _flat(np.asarray(raw["v"], dtype=float), 1), + "m": _flat(np.asarray(raw["m"], dtype=float), 0), + "best": best, + "summary": { + "start_m": settings["start_m"], + "end_m": settings["end_m"], + "f_min": round(float(best["f"]), 3), + "volume": round(float(best["volume"]), 1), + "method": METHOD_NAMES[int(best.get("method") or 0)], + "condition": settings["condition"], + "reference_fs": raw["reference_fs"], + "verdict": "ok" if best["f"] >= raw["reference_fs"] else "ng", + "by_method": { + k: round(float(best[k]), 3) for k in ("hovland", "bishop", "janbu_corrected") + }, + "chainage_m": float(_nearest_chainage(stations, *where)), + "cells": int(np.isfinite(f).sum()), + "trials": int(raw.get("trials") or 0), + }, + } + + +def save(project_root: Path, key: str, result: dict[str, Any]) -> None: + folder = results_dir(project_root) + folder.mkdir(parents=True, exist_ok=True) + tmp = folder / f"{key}.json.tmp" + tmp.write_text(json.dumps(result, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + tmp.replace(folder / f"{key}.json") + + +def load(project_root: Path, key: str) -> dict[str, Any] | None: + path = results_dir(project_root) / f"{key}.json" + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def saved_summaries(project_root: Path) -> list[dict[str, Any]]: + """저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용.""" + out = [] + for path in sorted(results_dir(project_root).glob("*.json")): + doc = json.loads(path.read_text(encoding="utf-8")) + out.append( + { + "key": path.stem, + "fingerprint": doc["fingerprint"], + "settings": doc["settings"], + **doc["summary"], + } + ) + return out + + +def analyze( + project_root: Path, + root: Path, + settings: dict[str, Any], + finger: str, + on_progress: Progress, + should_stop: StopFlag, +) -> dict[str, Any]: + """한 구간 3D 해석 → 저장본 쓰고 요약 돌려줌.""" + started = time.perf_counter() + stations = sorted(source.stations(root), key=lambda s: float(s["chainage_m"])) + picked = section_stations(stations, settings["start_m"], settings["end_m"]) + if len(picked) < 2: + raise ValueError("구간 안 측점이 둘 미만입니다.") + first, last = stations.index(picked[0]), stations.index(picked[-1]) + around = stations[max(first - 1, 0) : last + 2] # 앞뒤 이웃 — 구간 끝 합성 지형 이음 + ground = read_ground(project_root) + raw = run_engine(root, around, ground, settings, on_progress, should_stop) + result = pack(raw, picked, settings, finger, time.perf_counter() - started) + save(project_root, section_key(settings["start_m"], settings["end_m"]), result) + return result["summary"] diff --git a/E04_Stab3D/E04_Stab3D_UI_Map.ts b/E04_Stab3D/E04_Stab3D_UI_Map.ts new file mode 100644 index 000000000..407511ee3 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_Map.ts @@ -0,0 +1,381 @@ +/* ============================================================================= + * E04_Stab3D_UI_Map.ts + * E04 가운데 평면 지도(PLAN 533-3) — 셀별 최소 안전율 · 임계 체적 색 지도 · 임계 활동면 평면 윤곽 · + * 최소 위치 · 노선 · 측점 · 고른 구간 · 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤. + * 색 · 범례 · 셀 찾기 = logic/E04_Stab3D_Raster · 윤곽 셀 = logic/E04_Stab3D_Mesh(cutCells). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t } from "@ui/ui_template_locale"; +import { cutCells, footprintCenter } from "./logic/E04_Stab3D_Mesh"; +import { + FS_STEPS, + VOLUME_STEPS, + cellAt, + rasterRgba, + type Result3d, +} from "./logic/E04_Stab3D_Raster"; + +export type MapLayer = "fs" | "volume"; + +export interface StationPoint { + chainage_m: number; + label: string | null; + x: number; + y: number; +} + +export interface PlanMap { + root: HTMLElement; + setRoute: (line: number[][], stations: StationPoint[]) => void; + setRange: (start: number, end: number) => void; + setResult: (result: Result3d | null) => void; + setLayer: (layer: MapLayer) => void; + dispose: () => void; +} + +interface View { + cx: number; + cy: number; + scale: number; // px / m +} + +const RANGE_COLOR = "#2563eb"; // 고른 구간 — 3D 노선 관과 같은 색 · 안전율 색 단계와 겹치지 않음 + +const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); + +const css = (name: string, fallback: string): string => + getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + +export function createPlanMap(): PlanMap { + const root = el("div", { className: "e04-map" }); + const canvas = el("canvas", { className: "e04-map__canvas" }); + const tip = el("div", { className: "e04-map__tip" }); + const empty = el("p", { className: "e04-map__empty", text: t("E04_Map_Empty") }); + tip.hidden = true; + root.append(canvas, tip, empty); + const ctx = canvas.getContext("2d")!; + + let line: number[][] = []; + let stations: StationPoint[] = []; + let range: [number, number] | null = null; + let result: Result3d | null = null; + let layer: MapLayer = "fs"; + let image: HTMLCanvasElement | null = null; + let outline: Set = new Set(); + let marker: { x: number; y: number } | null = null; + let view: View = { cx: 0, cy: 0, scale: 1 }; + let width = 0; + let height = 0; + + const toScreen = (x: number, y: number): [number, number] => [ + width / 2 + (x - view.cx) * view.scale, + height / 2 - (y - view.cy) * view.scale, + ]; + const toModel = (px: number, py: number): [number, number] => [ + view.cx + (px - width / 2) / view.scale, + view.cy - (py - height / 2) / view.scale, + ]; + + function buildImage(): void { + image = null; + if (!result) return; + const { grid } = result; + const values = layer === "fs" ? result.f : result.v; + const steps = layer === "fs" ? FS_STEPS : VOLUME_STEPS; + const off = document.createElement("canvas"); + off.width = grid.nx; + off.height = grid.ny; + const rgba = rasterRgba(grid, values, steps); + off.getContext("2d")!.putImageData(new ImageData(rgba, grid.nx, grid.ny), 0, 0); + image = off; + } + + /** 맞춤 — 결과 격자 · 없으면 고른 구간 노선 · 없으면 노선 전체 */ + function fit(): void { + let xs: number[] = []; + let ys: number[] = []; + if (result) { + const g = result.grid; + xs = [g.x0 - g.dx / 2, g.x0 + (g.nx - 0.5) * g.dx]; + ys = [g.y0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx]; + } else { + const picked = range + ? line.filter((p) => p[2] >= range![0] - 1e-6 && p[2] <= range![1] + 1e-6) + : line; + const use = picked.length >= 2 ? picked : line; + xs = use.map((p) => p[0]); + ys = use.map((p) => p[1]); + if (range && picked.length >= 2) { + xs.push(Math.min(...xs) - 40, Math.max(...xs) + 40); + ys.push(Math.min(...ys) - 40, Math.max(...ys) + 40); + } + } + if (!xs.length || !width || !height) return; + const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)]; + const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.9; + view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale }; + } + + function drawRoute(text: string, accent: string): void { + if (line.length < 2) return; + ctx.lineJoin = ctx.lineCap = "round"; + ctx.strokeStyle = text; + ctx.lineWidth = 1.5; + ctx.beginPath(); + line.forEach((p, i) => + i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])), + ); + ctx.stroke(); + if (range) { + const part = line.filter((p) => p[2] >= range![0] - 1e-6 && p[2] <= range![1] + 1e-6); + ctx.strokeStyle = accent; + ctx.lineWidth = 5; + ctx.beginPath(); + part.forEach((p, i) => + i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])), + ); + ctx.stroke(); + } + // 측점 — 화면 간격이 좁으면 다섯 개마다 이름 + // 이름 = "STA.0+020.000" → "0+020" · 이름끼리 70 px 이상 떨어지게 1 · 2 · 5 · 10 … 개마다 + const gap = stations.length > 1 ? 20 * view.scale : 999; + const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100; + ctx.font = "11px sans-serif"; + ctx.textBaseline = "middle"; + stations.forEach((s, i) => { + const [px, py] = toScreen(s.x, s.y); + if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return; + ctx.fillStyle = text; + ctx.beginPath(); + ctx.arc(px, py, i % every === 0 ? 3 : 1.5, 0, Math.PI * 2); + ctx.fill(); + if (i % every === 0 && s.label) ctx.fillText(shortLabel(s.label), px + 6, py - 8); + }); + } + + /** 임계 활동면 평면 윤곽 — 잘린 셀 무리의 바깥 변 */ + function drawOutline(color: string): void { + if (!result || !outline.size) return; + const { grid } = result; + const h = grid.dx / 2; + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.beginPath(); + for (const index of outline) { + const ix = index % grid.nx; + const iy = Math.floor(index / grid.nx); + const x = grid.x0 + ix * grid.dx; + const y = grid.y0 + iy * grid.dx; + const edges: [number, number, number, number, number][] = [ + [index - 1, x - h, y - h, x - h, y + h], + [index + 1, x + h, y - h, x + h, y + h], + [index - grid.nx, x - h, y - h, x + h, y - h], + [index + grid.nx, x - h, y + h, x + h, y + h], + ]; + for (const [next, ax, ay, bx, by] of edges) { + const sameRow = + next === index - 1 || next === index + 1 ? Math.floor(next / grid.nx) === iy : true; + if (sameRow && outline.has(next)) continue; + ctx.moveTo(...toScreen(ax, ay)); + ctx.lineTo(...toScreen(bx, by)); + } + } + ctx.stroke(); + ctx.setLineDash([]); + } + + function drawMarker(text: string): void { + if (!result || !marker) return; + const { best } = result; + const [px, py] = toScreen(marker.x, marker.y); + ctx.lineWidth = 2; + ctx.strokeStyle = "#ffffff"; + ctx.beginPath(); + ctx.arc(px, py, 9, 0, Math.PI * 2); + ctx.stroke(); + ctx.strokeStyle = "#000000"; + ctx.beginPath(); + ctx.arc(px, py, 11, 0, Math.PI * 2); + ctx.stroke(); + ctx.font = "bold 12px sans-serif"; + ctx.fillStyle = text; + ctx.fillText(`Fs ${best.f.toFixed(3)}`, px + 14, py); + } + + function drawFurniture(text: string): void { + // 방위(오른쪽 위) + ctx.fillStyle = text; + ctx.strokeStyle = text; + ctx.beginPath(); + ctx.moveTo(width - 24, 14); + ctx.lineTo(width - 30, 34); + ctx.lineTo(width - 18, 34); + ctx.closePath(); + ctx.fill(); + ctx.font = "bold 12px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("N", width - 24, 46); + // 축척(오른쪽 아래) — 1 · 2 · 5 × 10ⁿ m + const target = 100 / view.scale; + const pow = 10 ** Math.floor(Math.log10(target)); + const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target; + const px = meters * view.scale; + ctx.fillStyle = css("--color-surface", "#ffffff"); + ctx.globalAlpha = 0.85; + ctx.fillRect(width - 24 - px, height - 42, px + 16, 34); + ctx.globalAlpha = 1; + ctx.fillStyle = text; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(width - 16 - px, height - 16); + ctx.lineTo(width - 16, height - 16); + ctx.stroke(); + ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26); + ctx.textAlign = "start"; + } + + function draw(): void { + const dpr = window.devicePixelRatio || 1; + width = root.clientWidth; + height = root.clientHeight; + if (!width || !height) return; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + const text = css("--color-text", "#1f2937"); + const accent = RANGE_COLOR; + if (result && image) { + const g = result.grid; + const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale); + } + drawRoute(text, accent); + drawOutline(text); + drawMarker(text); + drawFurniture(text); + empty.hidden = Boolean(result); + } + + // 조작 — 휠 확대(커서 자리 고정) · 끌기 옮김 · 두 번 누름 맞춤 · 셀 툴팁 + let drag: { x: number; y: number; view: View } | null = null; + canvas.addEventListener("wheel", (event) => { + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); + const k = Math.exp(-event.deltaY * 0.0015); + view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k }; + draw(); + }); + canvas.addEventListener("pointerdown", (event) => { + drag = { x: event.clientX, y: event.clientY, view: { ...view } }; + canvas.setPointerCapture(event.pointerId); + }); + canvas.addEventListener("pointerup", () => (drag = null)); + canvas.addEventListener("dblclick", () => { + fit(); + draw(); + }); + canvas.addEventListener("pointerleave", () => (tip.hidden = true)); + canvas.addEventListener("pointermove", (event) => { + if (drag) { + view = { + ...drag.view, + cx: drag.view.cx - (event.clientX - drag.x) / view.scale, + cy: drag.view.cy + (event.clientY - drag.y) / view.scale, + }; + draw(); + return; + } + const rect = canvas.getBoundingClientRect(); + const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); + const cell = result ? cellAt(result.grid, mx, my) : null; + const index = cell && result ? cell.iy * result.grid.nx + cell.ix : -1; + const f = index >= 0 ? result!.f[index] : null; + if (f === null) return void (tip.hidden = true); + const v = result!.v[index]; + const near = stations.reduce( + (best, s) => + !best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2 + ? s + : best, + null, + ); + tip.textContent = [ + `${t("E04_Layer_FS")} ${f.toFixed(3)}`, + `${t("E04_Layer_Volume")} ${v === null ? "-" : `${Math.round(v).toLocaleString()} m³`}`, + near ? `${t("E04_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "", + ] + .filter(Boolean) + .join("\n"); + tip.style.left = `${event.clientX - rect.left + 14}px`; + tip.style.top = `${event.clientY - rect.top + 14}px`; + tip.hidden = false; + }); + + const resize = new ResizeObserver(() => { + const first = !width; + draw(); + if (first) { + fit(); + draw(); + } + }); + resize.observe(root); + const theme = new MutationObserver(draw); + theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + + // 검증 창구 — 모델 좌표 → 화면(client) 좌표 · 지금 결과 + const probe = { + project: (x: number, y: number) => { + const rect = canvas.getBoundingClientRect(); + const [px, py] = toScreen(x, y); + return { x: rect.left + px, y: rect.top + py }; + }, + result: () => result, + marker: () => marker, + outline: () => [...outline], + layer: () => layer, + }; + (window as unknown as { __e04Map?: typeof probe }).__e04Map = probe; + + return { + root, + setRoute(nextLine, nextStations) { + line = nextLine; + stations = nextStations; + fit(); + draw(); + }, + setRange(start, end) { + range = [start, end]; + if (!result) fit(); + draw(); + }, + setResult(next) { + result = next; + const cut = next ? cutCells(next.grid, next.z, next.best) : new Map(); + outline = new Set(cut.keys()); + marker = next ? footprintCenter(next.grid, cut, next.best) : null; + buildImage(); + fit(); + draw(); + }, + setLayer(next) { + layer = next; + buildImage(); + draw(); + }, + dispose() { + resize.disconnect(); + theme.disconnect(); + if ((window as unknown as { __e04Map?: unknown }).__e04Map === probe) + delete (window as unknown as { __e04Map?: unknown }).__e04Map; + }, + }; +} diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts new file mode 100644 index 000000000..b7abbd9b8 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -0,0 +1,447 @@ +/* ============================================================================= + * E04_Stab3D_UI_Page.ts + * E04 안정성평가 3D 해석(요구 347-1 · 요구서 4-2 · 4-3 · PLAN 533-3) — B05~B09 틀(group_workflow). + * 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기] + * 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면) + * 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법) + * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage. + * ========================================================================== */ + +import "./E04_Stab3D_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map"; +import { createView3D } from "./E04_Stab3D_UI_View3D"; +import { buildSettingsCards, type Settings } from "./E04_Stab3D_UI_Settings"; +import { + FS_STEPS, + VOLUME_STEPS, + legendRows, + fs2dOf, + min2d, + ratio3d2d, + stepColor, + type Result3d, +} from "./logic/E04_Stab3D_Raster"; + +interface SavedSummary { + key: string; + start_m: number; + end_m: number; + f_min: number; + volume: number; + method: string; + stale: boolean; +} + +interface Overview { + editable: boolean; + sections: { start_m: number; end_m: number }[]; + stations: StationPoint[]; + line: number[][]; + saved: SavedSummary[]; + defaults: Settings; + max_section_m: number; +} + +interface Progress { + running: boolean; + percent: number; + message: string | null; + error: string | null; + cancelled: boolean; + key: string | null; +} + +const cacheKey = (projectId: string): string => `e04_settings:${projectId}`; +const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; + +async function api(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 T & { detail?: unknown }; + if (!response.ok) { + const detail = body.detail; + throw new Error(typeof detail === "string" ? detail : `HTTP ${response.status}`); + } + return body; +} + +/** E03 2D 측점 줄(GET …/stab/result2d · fs_by_condition · min = 조건별) — 없으면 빈 목록 */ +type Station2d = Parameters[0] & { chainage_m: number }; + +async function fetch2d(projectId: string): Promise { + try { + const body = await api<{ stations?: Station2d[] }>(`/projects/${projectId}/stab/result2d`); + return (body.stations ?? []).map((s) => ({ ...s, chainage_m: Number(s.chainage_m) })); + } catch { + return []; + } +} + +function legend(layer: MapLayer): HTMLElement { + const box = el("div", { className: "e04-legend" }); + box.append(el("strong", { text: t(layer === "fs" ? "E04_Legend_FS" : "E04_Legend_Volume") })); + const rows = layer === "fs" ? legendRows(FS_STEPS, 1) : legendRows(VOLUME_STEPS, 0); + for (const row of [...rows, { color: stepColor(FS_STEPS, null), label: t("E04_NoData") }]) { + const item = el("span", { className: "e04-legend__row" }); + const swatch = el("i", { className: "e04-legend__swatch" }); + swatch.style.background = row.color; + item.append(swatch, el("span", { text: row.label })); + box.append(item); + } + return box; +} + +export function renderE04Stab3D(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const panel = el("div", { className: "ui-sidebar-panel e04-panel" }); + const main = el("div", { className: "e04-page" }); + mountGroupStep(root, ROUTES.E04_STAB_3D, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e04-empty", text: t("E04_NoProject") })); + + // ---- 가운데 ---- + const map = createPlanMap(); + const view3d = createView3D(); + view3d.root.hidden = true; + let mode: "plan" | "3d" = "plan"; + let layer: MapLayer = "fs"; + const toggle = (labels: [LocaleKey, string][], onPick: (value: string) => void) => { + const group = el("div", { className: "e04-toggle" }); + const buttons = labels.map(([key, value], i) => { + const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); + button.dataset.value = value; + button.classList.toggle("is-on", i === 0); + button.addEventListener("click", () => { + buttons.forEach((b) => b.classList.toggle("is-on", b === button)); + onPick(value); + }); + return button; + }); + group.append(...buttons); + return group; + }; + const legendSlot = el("div", { className: "e04-legend-slot" }); + const paintLegend = (): void => legendSlot.replaceChildren(legend(layer)); + const stage = el("div", { className: "e04-stage" }); + stage.append(map.root, view3d.root, legendSlot); + const toolbar = el("div", { className: "e04-toolbar" }); + toolbar.append( + toggle( + [ + ["E04_View_Plan", "plan"], + ["E04_View_3D", "3d"], + ], + (value) => { + mode = value as "plan" | "3d"; + map.root.hidden = mode !== "plan"; + view3d.root.hidden = mode !== "3d"; + view3d.setActive(mode === "3d"); + if (mode === "3d") view3d.show(result, overview?.line ?? [], layer); + }, + ), + toggle( + [ + ["E04_Layer_FS", "fs"], + ["E04_Layer_Volume", "volume"], + ], + (value) => { + layer = value as MapLayer; + map.setLayer(layer); + if (mode === "3d") view3d.show(result, overview?.line ?? [], layer, false); + paintLegend(); + }, + ), + ); + const center = el("div", { className: "e04-center" }); + center.append(toolbar, stage); + paintLegend(); + + // ---- 우 ---- + const resultBody = el("div", { className: "e04-result" }); + const side = createSidePanels({ + prefKey: "e04-stab3d-result", + panels: [{ id: "result", title: t("E04_Result_Title"), body: resultBody }], + defaultOpen: "result", + defaultWidth: 320, + follow: false, + }); + const row = el("div", { className: "ui-side-panels-row e04-row" }); + row.append(center, side.root); + main.append(row); + + // ---- 좌 ---- + const sectionList = el("div", { className: "e04-sections" }); + const sectionCard = createPanelCard(t("E04_Card_Sections"), false); + const settings = buildSettingsCards(); + sectionCard.append(sectionList, settings.rangeRow); + const progressBar = el("progress", { className: "e04-progress", attrs: { max: "100" } }); + const progressText = el("p", { className: "e04-progress__text" }); + const cancelButton = createButton({ + label: t("E04_Cancel"), + variant: "danger-ghost", + size: "sm", + onClick: () => void api(`/projects/${projectId}/stab/analyze3d/cancel`, { method: "POST" }), + }); + const progressBox = el("div", { className: "e04-progress-box" }); + progressBox.append(progressBar, progressText, cancelButton); + progressBox.hidden = true; + const analyzeButton = createButton({ + label: t("E04_Analyze"), + variant: "filled", + onClick: () => void analyze(false), + }); + const reanalyzeButton = createButton({ + label: t("E04_Reanalyze"), + variant: "ghost", + onClick: () => void analyze(true), + }); + analyzeButton.disabled = reanalyzeButton.disabled = true; + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(reanalyzeButton, analyzeButton); + const readOnly = el("p", { className: "e04-hint", text: t("E04_ReadOnly") }); + readOnly.hidden = true; + panel.append( + el("p", { className: "e04-hint", text: t("E04_Hint") }), + readOnly, + sectionCard, + settings.basicCard, + settings.advancedCard, + progressBox, + actions, + ); + attachCollapsible(panel); + + // ---- 상태 ---- + let overview: Overview | null = null; + let result: Result3d | null = null; + let stations2d: Station2d[] = []; + let running = false; + + const remember = (): void => + sessionStorage.setItem(cacheKey(projectId), JSON.stringify(settings.read())); + let shownKey = ""; + settings.onChange(() => { + remember(); + const { start_m, end_m } = settings.read(); + map.setRange(start_m, end_m); + paintSections(); + if (shownKey !== sectionKey(start_m, end_m)) void openResult(); + }); + + function paintButtons(): void { + const editable = Boolean(overview?.editable); + analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + readOnly.hidden = !overview || editable; + } + + function paintSections(): void { + if (!overview) return; + const { start_m, end_m } = settings.read(); + const savedOf = new Map(overview.saved.map((s) => [s.key, s])); + const custom = overview.saved.filter( + (s) => !overview!.sections.some((x) => sectionKey(x.start_m, x.end_m) === s.key), + ); + const items = [ + ...overview.sections, + ...custom.map((s) => ({ start_m: s.start_m, end_m: s.end_m })), + ]; + sectionList.replaceChildren( + ...items.map((section) => { + const key = sectionKey(section.start_m, section.end_m); + const saved = savedOf.get(key); + const button = el("button", { + className: `e04-section${key === sectionKey(start_m, end_m) ? " is-active" : ""}`, + attrs: { type: "button", "data-key": key }, + }); + const chip = el("i", { className: "e04-section__chip" }); + chip.style.background = saved ? stepColor(FS_STEPS, saved.f_min) : "transparent"; + button.append( + chip, + el("span", { text: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m` }), + el("span", { + className: "e04-section__value", + text: saved + ? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}` + : "", + }), + ); + button.addEventListener("click", () => settings.setRange(section.start_m, section.end_m)); + return button; + }), + ); + } + + function paintResult(): void { + if (!result) { + resultBody.replaceChildren(el("p", { className: "e04-empty", text: t("E04_Result_None") })); + return; + } + const s = result.summary; + const f2d = min2d( + stations2d.map((x) => ({ chainage_m: x.chainage_m, fs_min: fs2dOf(x, s.condition) })), + s.start_m, + s.end_m, + ); + const ratio = ratio3d2d(s.f_min, f2d); + const station = overview?.stations.reduce( + (best, x) => + !best || Math.abs(x.chainage_m - s.chainage_m) < Math.abs(best.chainage_m - s.chainage_m) + ? x + : best, + null, + ); + const rows: [LocaleKey, string, string?][] = [ + ["E04_Result_F3d", s.f_min.toFixed(3), stepColor(FS_STEPS, s.f_min)], + [ + "E04_Result_Verdict", + `${t(s.verdict === "ok" ? "E04_Verdict_ok" : "E04_Verdict_ng")} (${t( + `E04_Condition_${s.condition}` as LocaleKey, + )} ≥ ${s.reference_fs.toFixed(2)})`, + ], + ["E04_Result_F2d", f2d === null ? t("E04_No2D") : f2d.toFixed(3)], + ["E04_Result_Ratio", ratio === null ? "-" : ratio.toFixed(3)], + ["E04_Result_Volume", `${Math.round(s.volume).toLocaleString()} m³`], + ["E04_Result_Method", t(`E04_MethodName_${s.method}` as LocaleKey)], + ["E04_Result_Bishop", s.by_method.bishop.toFixed(3)], + ["E04_Result_Janbu", s.by_method.janbu_corrected.toFixed(3)], + ["E04_Result_Hovland", s.by_method.hovland.toFixed(3)], + ["E04_Result_Where", station?.label ?? `${s.chainage_m.toFixed(1)} m`], + ["E04_Result_Cells", s.cells.toLocaleString()], + ["E04_Result_Trials", s.trials.toLocaleString()], + ["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`], + ]; + const list = el("dl", { className: "e04-result__list" }); + for (const [key, value, color] of rows) { + const dd = el("dd", { text: value, attrs: { "data-field": key } }); + if (key === "E04_Result_Verdict") dd.classList.add(`is-${s.verdict}`); + if (color) { + const chip = el("i", { className: "e04-section__chip" }); + chip.style.background = color; + dd.prepend(chip); + } + list.append(el("dt", { text: t(key) }), dd); + } + const notes: HTMLElement[] = []; + if (result.stale) notes.push(el("p", { className: "e04-warn", text: t("E04_Result_Stale") })); + notes.push(el("p", { className: "e04-note", text: t("E04_Result_Note") })); + resultBody.replaceChildren(list, ...notes); + } + + async function openResult(): Promise { + const { start_m, end_m } = settings.read(); + const key = sectionKey(start_m, end_m); + shownKey = key; + const saved = overview?.saved.some((s) => s.key === key); + result = saved + ? await api(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null) + : null; + if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜 + map.setResult(result); + if (mode === "3d") view3d.show(result, overview?.line ?? [], layer); + paintResult(); + } + + async function refresh(): Promise { + overview = await api(`/projects/${projectId}/stab/result3d`); + paintButtons(); + paintSections(); + } + + async function poll(): Promise { + while (root.isConnected) { + const p = await api(`/projects/${projectId}/stab/progress3d`).catch(() => null); + if (!p) break; + running = p.running; + progressBox.hidden = !p.running; + progressBar.value = p.percent; + progressText.textContent = `${t("E04_Running")} ${p.percent}% · ${p.message ?? ""}`; + paintButtons(); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else if (p.cancelled) showToast(t("E04_Cancelled"), "info"); + else showToast(t("E04_Done"), "success"); + await refresh(); + await openResult(); + return; + } + await new Promise((resolve) => setTimeout(resolve, 700)); + } + } + + async function analyze(force: boolean): Promise { + const values = settings.read(); + const { start_m, end_m, condition, ...search } = values; + try { + const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, { + method: "POST", + body: JSON.stringify({ start_m, end_m, condition, search, force }), + }); + if (body.status === "cached") { + showToast(t("E04_Cached"), "info"); + await openResult(); + return; + } + running = true; + paintButtons(); + progressBox.hidden = false; + void poll(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } + } + + // ---- 처음 ---- + void (async () => { + try { + await refresh(); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e04-empty", + text: error instanceof Error ? error.message : String(error), + }), + ); + return; + } + const ov = overview!; + settings.setDefaults(ov.defaults, ov.max_section_m); + const cached = JSON.parse( + sessionStorage.getItem(cacheKey(projectId)) ?? "null", + ) as Settings | null; + const first = ov.saved[0] ?? ov.sections[0]; + settings.write( + cached ?? { + ...ov.defaults, + start_m: first?.start_m ?? 0, + end_m: first?.end_m ?? 100, + condition: "dry", + }, + ); + map.setRoute(ov.line, ov.stations); + const { start_m, end_m } = settings.read(); + map.setRange(start_m, end_m); + paintSections(); + stations2d = await fetch2d(projectId); + await openResult(); + const p = await api(`/projects/${projectId}/stab/progress3d`).catch(() => null); + if (p?.running) void poll(); + })(); + + // 떠날 때 — WebGL · 관찰자 정리 + const leave = new MutationObserver(() => { + if (root.isConnected && main.isConnected) return; + leave.disconnect(); + map.dispose(); + view3d.dispose(); + }); + leave.observe(document.body, { childList: true, subtree: true }); +} diff --git a/E04_Stab3D/E04_Stab3D_UI_Settings.ts b/E04_Stab3D/E04_Stab3D_UI_Settings.ts new file mode 100644 index 000000000..215201303 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_Settings.ts @@ -0,0 +1,141 @@ +/* ============================================================================= + * E04_Stab3D_UI_Settings.ts + * E04 좌측 탐색 설정 칸(PLAN 533-3) — 구간 시작 · 끝 · 기본(조건 · 모양 · 탐색 폭) · 고급(접힘 · + * 기둥 · 구 중심 격자 · 반지름 증분 · 깊이 · 부피 제약) · [기본값]. 기본값 = 서버 SearchSettings. + * ========================================================================== */ + +import { createPanelCard } from "@ui/ui_template_collapsible"; +import { + createButton, + createInputField, + createSelectField, + el, + type InputFieldHandle, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +export interface Settings { + start_m: number; + end_m: number; + condition: string; + shape: string; + ellipsoid_ratio: number; + box_half_width_m: number; + cell_m: number; + spacing_m: number; + h_min_m: number; + h_max_m: number; + h_step_m: number; + dr_m: number; + max_depth_m: number; + v_min_m3: number; + v_max_m3: number; +} + +type NumberKey = Exclude; + +const BASIC: [NumberKey, LocaleKey][] = [ + ["ellipsoid_ratio", "E04_Ratio"], + ["box_half_width_m", "E04_BoxWidth"], +]; +const ADVANCED: [NumberKey, LocaleKey][] = [ + ["cell_m", "E04_Cell"], + ["spacing_m", "E04_Spacing"], + ["h_min_m", "E04_HMin"], + ["h_max_m", "E04_HMax"], + ["h_step_m", "E04_HStep"], + ["dr_m", "E04_Dr"], + ["max_depth_m", "E04_MaxDepth"], + ["v_min_m3", "E04_VMin"], + ["v_max_m3", "E04_VMax"], +]; + +export function buildSettingsCards() { + const fields = new Map(); + const listeners: (() => void)[] = []; + const changed = (): void => listeners.forEach((fn) => fn()); + const number = (key: NumberKey, label: LocaleKey): HTMLElement => { + const field = createInputField({ label: t(label), type: "number", compact: true }); + field.input.step = "any"; + field.input.dataset.key = key; + field.input.addEventListener("change", changed); + fields.set(key, field); + return field.root; + }; + + const rangeRow = el("div", { className: "e04-range" }); + rangeRow.append(number("start_m", "E04_Start"), number("end_m", "E04_End")); + + const shape = createSelectField({ + label: t("E04_Shape"), + compact: true, + options: (["both", "sphere"] as const).map((v) => ({ + value: v, + text: t(`E04_Shape_${v}` as LocaleKey), + })), + onChange: changed, + }); + const condition = createSelectField({ + label: t("E04_Condition"), + compact: true, + options: (["dry", "wet"] as const).map((v) => ({ + value: v, + text: t(`E04_Condition_${v}` as LocaleKey), + })), + onChange: changed, + }); + const basicCard = createPanelCard(t("E04_Card_Search"), false); + basicCard.append(condition.root, shape.root, ...BASIC.map(([k, l]) => number(k, l))); + + let defaults: Partial = {}; + const advancedCard = createPanelCard(t("E04_Card_Advanced"), true); + const grid = el("div", { className: "e04-advanced" }); + grid.append(...ADVANCED.map(([k, l]) => number(k, l))); + advancedCard.append( + grid, + createButton({ + label: t("E04_Defaults"), + variant: "ghost", + size: "sm", + onClick: () => { + const range = { start_m: read().start_m, end_m: read().end_m }; + write({ ...read(), ...defaults, ...range, condition: "dry" }); + changed(); + }, + }), + ); + + function read(): Settings { + const out = { shape: shape.select.value, condition: condition.select.value } as Settings; + for (const [key, field] of fields) out[key] = Number(field.input.value); + return out; + } + + function write(values: Partial): void { + for (const [key, field] of fields) { + const value = values[key]; + if (typeof value === "number" && Number.isFinite(value)) field.input.value = String(value); + } + if (values.shape) shape.select.value = values.shape; + if (values.condition) condition.select.value = values.condition; + } + + return { + rangeRow, + basicCard, + advancedCard, + read, + write, + setRange(start: number, end: number) { + write({ start_m: start, end_m: end }); + changed(); + }, + setDefaults(next: Partial, maxSection: number) { + defaults = next; + fields.get("end_m")!.input.title = `≤ ${maxSection} m`; + }, + onChange(fn: () => void) { + listeners.push(fn); + }, + }; +} diff --git a/E04_Stab3D/E04_Stab3D_UI_Style.css b/E04_Stab3D/E04_Stab3D_UI_Style.css new file mode 100644 index 000000000..f215ec07a --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_Style.css @@ -0,0 +1,286 @@ +/* ============================================================================= + * E04_Stab3D_UI_Style.css + * E04 3D 해석 — 좌측 구간 · 설정 · 진행 / 가운데 지도 · 3D · 범례 / 우측 결과 + * 색은 테마 토큰만(밝음 · 어두움 같이) · 안전율 · 체적 색 단계만 logic 고정색 + * ========================================================================== */ + +.e04-page { + height: 100dvh; + box-sizing: border-box; +} + +.e04-row { + height: 100%; +} + +.e04-center { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; + padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12); + box-sizing: border-box; +} + +.e04-toolbar { + display: flex; + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.e04-toggle { + display: inline-flex; + gap: var(--spacing-4); +} + +.e04-toggle .ui-btn.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); +} + +.e04-stage { + position: relative; + flex: 1 1 auto; + min-height: 320px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + overflow: hidden; +} + +.e04-map, +.e04-view3d { + position: absolute; + inset: 0; +} + +.e04-map[hidden], +.e04-view3d[hidden] { + display: none; +} + +.e04-map__canvas, +.e04-view3d__canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; +} + +.e04-map__canvas { + cursor: grab; +} + +.e04-map__tip { + position: absolute; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; + white-space: pre; + pointer-events: none; +} + +.e04-map__empty { + position: absolute; + inset: var(--spacing-16) 0 auto 0; + margin: 0; + text-align: center; + color: var(--color-text-muted); + pointer-events: none; +} + +.e04-view3d__compass { + position: absolute; + top: var(--spacing-8); + right: var(--spacing-8); +} + +.e04-view3d__tools { + position: absolute; + top: var(--spacing-8); + left: var(--spacing-8); + display: flex; + gap: var(--spacing-12); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font-size: 12px; +} + +.e04-view3d__toggle { + display: inline-flex; + align-items: center; + gap: var(--spacing-4); +} + +.e04-legend-slot { + position: absolute; + left: var(--spacing-8); + bottom: var(--spacing-8); + pointer-events: none; +} + +.e04-legend { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; +} + +.e04-legend__row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.e04-legend__swatch, +.e04-section__chip { + display: inline-block; + width: 14px; + height: 10px; + flex: none; + border-radius: 2px; + border: 1px solid var(--color-border); +} + +/* 좌측 */ +.e04-hint, +.e04-note { + margin: 0 0 var(--spacing-8); + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.5; +} + +.e04-sections { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 240px; + overflow: auto; +} + +.e04-section { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.e04-section:hover { + background: var(--color-surface-raised); +} + +.e04-section.is-active { + border-color: var(--color-primary); +} + +.e04-section__value { + color: var(--color-text-muted); + font-size: 12px; +} + +.e04-range .ui-field, +.e04-advanced .ui-field { + min-width: 0; +} + +.e04-range .ui-input, +.e04-advanced .ui-input { + width: 100%; + box-sizing: border-box; +} + +.e04-range, +.e04-advanced { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-8); + margin-top: var(--spacing-8); +} + +.e04-progress-box { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} + +.e04-progress-box[hidden] { + display: none; +} + +.e04-progress { + width: 100%; +} + +.e04-progress__text { + margin: 0; + color: var(--color-text); + font-size: 12px; +} + +/* 우측 */ +.e04-result { + padding: var(--spacing-12); +} + +.e04-result__list { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-8) var(--spacing-12); + margin: 0; + font-size: 13px; +} + +.e04-result__list dt { + color: var(--color-text-muted); +} + +.e04-result__list dd { + display: flex; + align-items: center; + gap: var(--spacing-8); + margin: 0; + color: var(--color-text); + font-weight: 600; + justify-content: flex-end; +} + +.e04-result__list dd.is-ok { + color: var(--color-success); +} + +.e04-result__list dd.is-ng { + color: var(--color-danger); +} + +.e04-warn { + margin: var(--spacing-12) 0 0; + color: var(--color-danger); + font-size: 12px; +} + +.e04-empty { + margin: var(--spacing-16); + color: var(--color-text-muted); +} diff --git a/E04_Stab3D/E04_Stab3D_UI_View3D.ts b/E04_Stab3D/E04_Stab3D_UI_View3D.ts new file mode 100644 index 000000000..3986d8681 --- /dev/null +++ b/E04_Stab3D/E04_Stab3D_UI_View3D.ts @@ -0,0 +1,242 @@ +/* ============================================================================= + * E04_Stab3D_UI_View3D.ts + * E04 [3D 보기](PLAN 533-3) — 합성 지표(원지형 + 노선면 · 셀 색 = 최소 안전율 · 임계 체적) + + * 노선 중심선 + 임계 활동면(구 · 타원체를 지표 아래만 남긴 잘린 면 · 그 위 활동체는 걷어냄) · 표시만. + * E 복사본에는 B04 지표면 모델 행 · 미리보기 GLB 가 없어 B05 `createRouteViewer.loadSurface` 대신 + * 해석 격자(z)를 그대로 그림 — 좌표 변환은 B05 `modelToScene` 과 같은 꼴(x · z · −y). + * 메쉬 · 색 = logic/E04_Stab3D_Mesh · logic/E04_Stab3D_Raster. + * ========================================================================== */ + +import * as THREE from "three"; +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { createTerrainCompass } from "@ui/ui_template_compass"; +import { el } from "@ui/ui_template_elements"; +import { t } from "@ui/ui_template_locale"; +import { + cutCells, + heightAt, + slipMesh, + terrainMesh, + terrainWithout, + type MeshArrays, +} from "./logic/E04_Stab3D_Mesh"; +import { + FS_STEPS, + VOLUME_STEPS, + hexToRgb, + stepColor, + type Result3d, +} from "./logic/E04_Stab3D_Raster"; +import type { MapLayer } from "./E04_Stab3D_UI_Map"; + +export interface View3D { + root: HTMLElement; + show: (result: Result3d | null, line: number[][], layer: MapLayer, aim?: boolean) => void; + setActive: (active: boolean) => void; + dispose: () => void; +} + +const LIGHT_BG = 0xf5f7fa; +const DARK_BG = 0x251f38; +const SLIP_COLOR = 0x8b1a2b; +const ROUTE_COLOR = 0x2563eb; + +export function createView3D(): View3D { + const root = el("div", { className: "e04-view3d" }); + const canvas = el("canvas", { className: "e04-view3d__canvas" }); + const compass = createTerrainCompass({ sizePx: 88, className: "e04-view3d__compass" }); + // [임계 활동면] 켬 = 활동체를 걷어낸 지표 + 잘린 활동면(Scoops3D 꼴) · 끔 = 원래 지표 + const slipToggle = el("input", { attrs: { type: "checkbox" } }); + slipToggle.checked = true; + const tools = el("div", { className: "e04-view3d__tools" }); + const label = el("label", { className: "e04-view3d__toggle" }); + label.append(slipToggle, el("span", { text: t("E04_SlipSurface") })); + tools.append(label); + root.append(canvas, compass.root, tools); + + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio || 1); + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(45, 1, 0.5, 20000); + camera.up.set(0, 1, 0); + const controls = new OrbitControls(camera, canvas); + controls.enableDamping = true; + scene.add(new THREE.HemisphereLight(0xffffff, 0x64748b, 2.2)); + const sun = new THREE.DirectionalLight(0xffffff, 1.4); + sun.position.set(-1, 2, 1); + scene.add(sun); + const content = new THREE.Group(); + scene.add(content); + + const background = (): void => { + const theme = document.documentElement.getAttribute("data-theme"); + const dark = + theme === "dark" || + (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches); + scene.background = new THREE.Color(dark ? DARK_BG : LIGHT_BG); + }; + background(); + const themeWatch = new MutationObserver(background); + themeWatch.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + + let origin = { x: 0, y: 0, z: 0 }; + let terrain: THREE.Mesh | null = null; + let slip: THREE.Mesh | null = null; + let counts = { terrain: 0, slip: 0, slipTriangles: 0 }; + const toScene = (x: number, y: number, z: number): [number, number, number] => [ + x - origin.x, + z - origin.z, + -(y - origin.y), + ]; + + function geometry(mesh: MeshArrays, colors?: Float32Array): THREE.BufferGeometry { + const out = new Float32Array(mesh.positions.length); + for (let i = 0; i < mesh.positions.length; i += 3) { + out.set(toScene(mesh.positions[i], mesh.positions[i + 1], mesh.positions[i + 2]), i); + } + const g = new THREE.BufferGeometry(); + g.setAttribute("position", new THREE.BufferAttribute(out, 3)); + if (colors) g.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + g.setIndex(new THREE.BufferAttribute(mesh.indices, 1)); + g.computeVertexNormals(); + return g; + } + + function clear(): void { + for (const child of [...content.children]) { + content.remove(child); + child.traverse((node) => { + const mesh = node as THREE.Mesh; + mesh.geometry?.dispose(); + const material = mesh.material as THREE.Material | THREE.Material[] | undefined; + (Array.isArray(material) ? material : material ? [material] : []).forEach((m) => + m.dispose(), + ); + }); + } + terrain = slip = null; + } + + let last: { result: Result3d | null; line: number[][]; layer: MapLayer } | null = null; + slipToggle.addEventListener("change", () => { + if (last) show(last.result, last.line, last.layer, false); + }); + + function show(result: Result3d | null, line: number[][], layer: MapLayer, aim = true): void { + last = { result, line, layer }; + clear(); + counts = { terrain: 0, slip: 0, slipTriangles: 0 }; + if (!result) return; + const { grid, z, best } = result; + const zs = z.filter((v): v is number => v !== null); + origin = { + x: grid.x0 + ((grid.nx - 1) * grid.dx) / 2, + y: grid.y0 + ((grid.ny - 1) * grid.dx) / 2, + z: zs.reduce((a, b) => a + b, 0) / Math.max(zs.length, 1), + }; + // 지표 — 셀 색 = 지금 층(안전율 · 체적) + const cut = cutCells(grid, z, best); + const ground = slipToggle.checked ? terrainWithout(grid, z, cut) : terrainMesh(grid, z); + const values = layer === "fs" ? result.f : result.v; + const steps = layer === "fs" ? FS_STEPS : VOLUME_STEPS; + const colors = new Float32Array(ground.cells.length * 3); + ground.cells.forEach((cell, i) => colors.set(hexToRgb(stepColor(steps, values[cell])), i * 3)); + terrain = new THREE.Mesh( + geometry(ground, colors), + new THREE.MeshLambertMaterial({ vertexColors: true, side: THREE.DoubleSide }), + ); + content.add(terrain); + counts.terrain = ground.cells.length; + // 임계 활동면 — 잘린 구 · 타원체 + 테두리(지표 높이) · 양면 · 옅은 격자선 + const surface = slipMesh(grid, z, best); + counts.slip = cut.size; + counts.slipTriangles = surface.indices.length / 3; + if (surface.indices.length && slipToggle.checked) { + const g = geometry(surface); + slip = new THREE.Mesh( + g, + new THREE.MeshPhongMaterial({ color: SLIP_COLOR, side: THREE.DoubleSide, shininess: 30 }), + ); + slip.add( + new THREE.LineSegments( + new THREE.WireframeGeometry(g), + new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.25 }), + ), + ); + content.add(slip); + } + // 노선 중심선 — 지표 위 0.4 m 관(굵기 0.35 m) + const points = line + .map((p) => ({ p, h: heightAt(grid, z, p[0], p[1]) })) + .filter((q): q is { p: number[]; h: number } => q.h !== null) + .map(({ p, h }) => new THREE.Vector3(...toScene(p[0], p[1], h + 0.4))); + if (points.length >= 2) { + const path = new THREE.CatmullRomCurve3(points, false, "centripetal"); + content.add( + new THREE.Mesh( + new THREE.TubeGeometry(path, points.length * 4, 0.35, 6, false), + new THREE.MeshLambertMaterial({ color: ROUTE_COLOR }), + ), + ); + } + if (!aim) return; + // 사시도 — 남동쪽 위에서 격자 전체가 들어오게 + const span = Math.max(grid.nx, grid.ny) * grid.dx; + const focus = new THREE.Vector3(...toScene(best.x, best.y, best.z - best.r)); + controls.target.copy(focus); + camera.position.set(focus.x + span * 0.55, focus.y + span * 0.6, focus.z + span * 0.75); + controls.update(); + } + + let frame = 0; + const loop = (): void => { + frame = requestAnimationFrame(loop); + const w = root.clientWidth; + const h = root.clientHeight; + if ( + w && + h && + (canvas.width !== Math.round(w * renderer.getPixelRatio()) || camera.aspect !== w / h) + ) { + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); + } + controls.update(); + compass.update( + camera.position.x - controls.target.x, + camera.position.y - controls.target.y, + camera.position.z - controls.target.z, + ); + renderer.render(scene, camera); + }; + + const probe = { counts: () => ({ ...counts }), scene, camera }; + (window as unknown as { __e04View?: typeof probe }).__e04View = probe; + + return { + root, + show, + setActive(active) { + if (active && !frame) loop(); + if (!active && frame) { + cancelAnimationFrame(frame); + frame = 0; + } + }, + dispose() { + cancelAnimationFrame(frame); + frame = 0; + clear(); + themeWatch.disconnect(); + controls.dispose(); + renderer.dispose(); + renderer.forceContextLoss(); + if ((window as unknown as { __e04View?: unknown }).__e04View === probe) + delete (window as unknown as { __e04View?: unknown }).__e04View; + }, + }; +} diff --git a/E04_Stab3D/draft/E04_Stab3D_Route.ts b/E04_Stab3D/draft/E04_Stab3D_Route.ts new file mode 100644 index 000000000..34dd043be --- /dev/null +++ b/E04_Stab3D/draft/E04_Stab3D_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E04_Stab3D_Route.ts + * 길 선언(PLAN 529-1 · 533-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E04 3D 해석 페이지 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E04_STAB_3D, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E04_Stab3D_UI_Page")).renderE04Stab3D, +}; diff --git a/E04_Stab3D/logic/E04_Stab3D_Mesh.ts b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts new file mode 100644 index 000000000..b334d60f9 --- /dev/null +++ b/E04_Stab3D/logic/E04_Stab3D_Mesh.ts @@ -0,0 +1,171 @@ +/* ============================================================================= + * E04_Stab3D_Mesh.ts + * E04 3D 보기 메쉬(PLAN 533-3) — 합성 지표(원지형 + 노선면) 격자 메쉬 · 임계 활동면(구 · 타원체를 + * 지표 아래만 남긴 잘린 면) · 지표 높이 보간. 좌표 = 모델(x 동 · y 북 · z 위) 그대로 — + * 화면 좌표 변환은 화면 파일 몫. 순수 함수만(DOM · three 금지). + * ========================================================================== */ + +import type { Grid, SlipBest } from "./E04_Stab3D_Raster"; + +export interface MeshArrays { + /** 꼭짓점 [x, y, z, …] */ + positions: Float32Array; + /** 삼각형 꼭짓점 번호 */ + indices: Uint32Array; + /** 꼭짓점마다 래스터 셀 번호(색칠 · 툴팁용) */ + cells: Int32Array; +} + +/** 격자 위 값이 있는 꼭짓점만 이어 메쉬 — 네 모서리 중 셋 이상 있으면 삼각형 */ +function gridMesh(grid: Grid, height: (index: number) => number | null): MeshArrays { + const vertexOf = new Int32Array(grid.nx * grid.ny).fill(-1); + const positions: number[] = []; + const cells: number[] = []; + for (let iy = 0; iy < grid.ny; iy += 1) { + for (let ix = 0; ix < grid.nx; ix += 1) { + const index = iy * grid.nx + ix; + const z = height(index); + if (z === null) continue; + vertexOf[index] = cells.length; + positions.push(grid.x0 + ix * grid.dx, grid.y0 + iy * grid.dx, z); + cells.push(index); + } + } + const indices: number[] = []; + for (let iy = 0; iy + 1 < grid.ny; iy += 1) { + for (let ix = 0; ix + 1 < grid.nx; ix += 1) { + const a = vertexOf[iy * grid.nx + ix]; + const b = vertexOf[iy * grid.nx + ix + 1]; + const c = vertexOf[(iy + 1) * grid.nx + ix + 1]; + const d = vertexOf[(iy + 1) * grid.nx + ix]; + // 반시계(위에서 봄) — a b c · a c d + if (a >= 0 && b >= 0 && c >= 0 && d >= 0) indices.push(a, b, c, a, c, d); + else if (a >= 0 && b >= 0 && c >= 0) indices.push(a, b, c); + else if (a >= 0 && c >= 0 && d >= 0) indices.push(a, c, d); + else if (a >= 0 && b >= 0 && d >= 0) indices.push(a, b, d); + else if (b >= 0 && c >= 0 && d >= 0) indices.push(b, c, d); + } + } + return { + positions: Float32Array.from(positions), + indices: Uint32Array.from(indices), + cells: Int32Array.from(cells), + }; +} + +/** 합성 지표 메쉬 — z 가 null 인 셀은 뺌 */ +export function terrainMesh(grid: Grid, z: readonly (number | null)[]): MeshArrays { + return gridMesh(grid, (index) => z[index]); +} + +/** 활동면 깊이 — 구 · 타원체 아래 반쪽 높이(밖이면 null) */ +export function slipHeight(best: SlipBest, x: number, y: number): number | null { + const az = (best.azimuth_deg * Math.PI) / 180; + const dx = x - best.x; + const dy = y - best.y; + // 활동 방향 성분 · 회전축(수직) 성분 — 축 방향만 ratio 로 나눔 + const along = dx * Math.sin(az) + dy * Math.cos(az); + const axis = (dx * Math.cos(az) - dy * Math.sin(az)) / Math.max(best.ratio, 1e-9); + const rest = best.r * best.r - along * along - axis * axis; + return rest < 0 ? null : best.z - Math.sqrt(rest); +} + +/** 잘린 셀 → 활동면 높이(활동면이 지표보다 낮은 셀만 · 못 뚫는 층 윗면 아래로는 안 내려감 · 엔진 덩이 셀만) */ +export function cutCells( + grid: Grid, + z: readonly (number | null)[], + best: SlipBest, +): Map { + const floor = new Map(best.floor ?? []); + const mass = best.cells ? new Set(best.cells) : null; + const out = new Map(); + z.forEach((top, index) => { + if (top === null || (mass && !mass.has(index))) return; + const x = grid.x0 + (index % grid.nx) * grid.dx; + const y = grid.y0 + Math.floor(index / grid.nx) * grid.dx; + const slip = slipHeight(best, x, y); + const base = Math.max(slip ?? top, floor.get(index) ?? -Infinity); + // 엔진 덩이 셀 = 그대로(셀 안 일부만 잘린 가장자리 = 두께 0) · 옛 저장본 = 셀 중심이 잘린 칸만 + if (mass) out.set(index, Math.min(base, top)); + else if (base < top) out.set(index, base); + }); + return out; +} + +/** 임계 활동체 평면 중심 = 잘린 셀 중심의 평균(없으면 구 중심) — 지도 최소 표시 자리 */ +export function footprintCenter( + grid: Grid, + cut: ReadonlyMap, + best: SlipBest, +): { x: number; y: number } { + if (!cut.size) return { x: best.x, y: best.y }; + let sx = 0; + let sy = 0; + for (const index of cut.keys()) { + sx += grid.x0 + (index % grid.nx) * grid.dx; + sy += grid.y0 + Math.floor(index / grid.nx) * grid.dx; + } + return { x: sx / cut.size, y: sy / cut.size }; +} + +/** 잘린 셀의 8 이웃 중 잘리지 않은 셀(값 있음) — 활동면 테두리 */ +export function rimCells( + grid: Grid, + z: readonly (number | null)[], + cut: ReadonlyMap, +): Set { + const rim = new Set(); + for (const index of cut.keys()) { + const ix = index % grid.nx; + const iy = Math.floor(index / grid.nx); + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + const jx = ix + dx; + const jy = iy + dy; + if (jx < 0 || jy < 0 || jx >= grid.nx || jy >= grid.ny) continue; + const next = jy * grid.nx + jx; + if (!cut.has(next) && z[next] !== null) rim.add(next); + } + } + } + return rim; +} + +/** 임계 활동면 = 잘린 셀(활동면 높이) + 테두리 셀(지표 높이) — 지표 구멍(잘린 셀)을 틈 없이 메움 */ +export function slipMesh(grid: Grid, z: readonly (number | null)[], best: SlipBest): MeshArrays { + const cut = cutCells(grid, z, best); + const rim = rimCells(grid, z, cut); + return gridMesh(grid, (index) => cut.get(index) ?? (rim.has(index) ? z[index] : null)); +} + +/** 지표에서 잘린 셀을 뺀 메쉬(활동체를 걷어낸 모습) */ +export function terrainWithout( + grid: Grid, + z: readonly (number | null)[], + cut: ReadonlyMap, +): MeshArrays { + return gridMesh(grid, (index) => (cut.has(index) ? null : z[index])); +} + +/** 지표 높이 쌍선형 보간(격자 밖 · 빈 셀 = null) */ +export function heightAt( + grid: Grid, + z: readonly (number | null)[], + x: number, + y: number, +): number | null { + const fx = (x - grid.x0) / grid.dx; + const fy = (y - grid.y0) / grid.dx; + const ix = Math.floor(fx); + const iy = Math.floor(fy); + if (ix < 0 || iy < 0 || ix + 1 >= grid.nx || iy + 1 >= grid.ny) return null; + const at = (i: number, j: number): number | null => z[j * grid.nx + i]; + const z00 = at(ix, iy); + const z10 = at(ix + 1, iy); + const z01 = at(ix, iy + 1); + const z11 = at(ix + 1, iy + 1); + if (z00 === null || z10 === null || z01 === null || z11 === null) return null; + const tx = fx - ix; + const ty = fy - iy; + return (z00 * (1 - tx) + z10 * tx) * (1 - ty) + (z01 * (1 - tx) + z11 * tx) * ty; +} diff --git a/E04_Stab3D/logic/E04_Stab3D_Raster.ts b/E04_Stab3D/logic/E04_Stab3D_Raster.ts new file mode 100644 index 000000000..7cbb809bf --- /dev/null +++ b/E04_Stab3D/logic/E04_Stab3D_Raster.ts @@ -0,0 +1,215 @@ +/* ============================================================================= + * E04_Stab3D_Raster.ts + * E04 3D 해석 화면 계산(PLAN 533-3) — 래스터 꼴 · 색 단계 · 범례 · 셀 찾기 · 3D/2D 비. + * 래스터 꼴 = 서버 `E04_Stab3D_Run.py` 머리 주석(grid · z · f · v · m · best). + * 순수 함수만(DOM · three · fetch 금지). + * ========================================================================== */ + +export interface Grid { + x0: number; + y0: number; + dx: number; + nx: number; + ny: number; +} + +/** 전체 최소 활동면 — 구(ratio 1) · 타원체(회전축 방향 반축 = ratio·r · 활동 방향에 수직) */ +export interface SlipBest { + x: number; + y: number; + z: number; + r: number; + ratio: number; + azimuth_deg: number; // 활동 방향(북 0° · 시계) + f: number; + volume: number; + method: number; + ix: number; + iy: number; + /** 못 뚫는 층에 잘린 셀 — [셀 번호, 층 윗면] · 활동면을 그 높이에서 자름 */ + floor?: [number, number][]; + /** 활동체 덩이 셀 번호(엔진 · 한 구가 자른 여러 덩이 중 이 덩이) — 없으면(옛 저장본) 잘린 셀 전부 */ + cells?: number[]; +} + +export interface Summary3d { + start_m: number; + end_m: number; + f_min: number; + volume: number; + method: string; + chainage_m: number; + cells: number; + trials: number; + condition: string; + reference_fs: number; + verdict: "ok" | "ng"; + /** 같은 임계면의 방법별 안전율 */ + by_method: { hovland: number; bishop: number; janbu_corrected: number }; +} + +export interface Result3d { + key: string; + stale: boolean; + computed_at: string; + elapsed_s: number; + settings: Record; + grid: Grid; + z: (number | null)[]; + f: (number | null)[]; + v: (number | null)[]; + m: (number | null)[]; + best: SlipBest; + summary: Summary3d; +} + +export interface ColorStep { + /** 이 값 미만이면 이 색(마지막은 Infinity) */ + below: number; + color: string; +} + +/** 안전율 색 단계 — 1.3(우기) · 1.5(건기) = KDS 11 70 05 기준 경계 · 붉을수록 위험 */ +export const FS_STEPS: readonly ColorStep[] = [ + { below: 1.0, color: "#a50026" }, + { below: 1.2, color: "#d73027" }, + { below: 1.3, color: "#f46d43" }, + { below: 1.5, color: "#fdae61" }, + { below: 2.0, color: "#fee08b" }, + { below: 3.0, color: "#a6d96a" }, + { below: Infinity, color: "#1a9850" }, +]; + +/** 임계 체적 색 단계(m³ · 10 배씩) — 짙을수록 큼 */ +export const VOLUME_STEPS: readonly ColorStep[] = [ + { below: 10, color: "#fde725" }, + { below: 100, color: "#5ec962" }, + { below: 1000, color: "#21918c" }, + { below: 10000, color: "#3b528b" }, + { below: Infinity, color: "#440154" }, +]; + +export const NO_DATA_COLOR = "#9aa3ad"; + +/** 값 → 단계 번호(null · NaN = -1) */ +export function stepIndex(steps: readonly ColorStep[], value: number | null): number { + if (value === null || !Number.isFinite(value)) return -1; + return steps.findIndex((step) => value < step.below); +} + +export function stepColor(steps: readonly ColorStep[], value: number | null): string { + const index = stepIndex(steps, value); + return index < 0 ? NO_DATA_COLOR : steps[index].color; +} + +/** "#rrggbb" → [r, g, b] 0~1 */ +export function hexToRgb(hex: string): [number, number, number] { + const n = Number.parseInt(hex.slice(1), 16); + return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; +} + +const fmt = (value: number, digits: number): string => + value >= 1000 ? value.toLocaleString("en-US") : value.toFixed(digits); + +/** 범례 줄 — "< 1.0" · "1.0 – 1.2" · … · "≥ 3.0" */ +export function legendRows( + steps: readonly ColorStep[], + digits: number, +): { color: string; label: string }[] { + return steps.map((step, index) => { + const low = index === 0 ? null : steps[index - 1].below; + const label = + low === null + ? `< ${fmt(step.below, digits)}` + : step.below === Infinity + ? `≥ ${fmt(low, digits)}` + : `${fmt(low, digits)} – ${fmt(step.below, digits)}`; + return { color: step.color, label }; + }); +} + +/** 셀 (ix, iy) 중심 모델 좌표 */ +export function cellCenter(grid: Grid, ix: number, iy: number): { x: number; y: number } { + return { x: grid.x0 + ix * grid.dx, y: grid.y0 + iy * grid.dx }; +} + +/** 모델 좌표 → 셀(격자 밖 = null) */ +export function cellAt(grid: Grid, x: number, y: number): { ix: number; iy: number } | null { + const ix = Math.round((x - grid.x0) / grid.dx); + const iy = Math.round((y - grid.y0) / grid.dx); + return ix < 0 || iy < 0 || ix >= grid.nx || iy >= grid.ny ? null : { ix, iy }; +} + +/** 래스터 최소 셀(값 없으면 null) */ +export function rasterMin( + grid: Grid, + values: readonly (number | null)[], +): { ix: number; iy: number; value: number } | null { + let best = -1; + values.forEach((value, index) => { + if (value !== null && (best < 0 || value < (values[best] as number))) best = index; + }); + return best < 0 + ? null + : { ix: best % grid.nx, iy: Math.floor(best / grid.nx), value: values[best] as number }; +} + +/** 값이 있는 셀 수 */ +export function countCells(values: readonly (number | null)[]): number { + return values.reduce((n, value) => (value === null ? n : n + 1), 0); +} + +/** 래스터 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 값 없는 셀 = 투명 */ +export function rasterRgba( + grid: Grid, + values: readonly (number | null)[], + steps: readonly ColorStep[], +): Uint8ClampedArray { + const out = new Uint8ClampedArray(grid.nx * grid.ny * 4); + const rgb = steps.map((step) => hexToRgb(step.color).map((c) => Math.round(c * 255))); + for (let iy = 0; iy < grid.ny; iy += 1) { + const row = grid.ny - 1 - iy; + for (let ix = 0; ix < grid.nx; ix += 1) { + const index = stepIndex(steps, values[iy * grid.nx + ix]); + if (index < 0) continue; + const at = (row * grid.nx + ix) * 4; + out[at] = rgb[index][0]; + out[at + 1] = rgb[index][1]; + out[at + 2] = rgb[index][2]; + out[at + 3] = 255; + } + } + return out; +} + +/** 2D 측점 한 줄의 3D 조건 안전율 — 지배 비탈 fs_by_condition · 없으면 조건별 최소 min · 없으면 null */ +export function fs2dOf( + row: { + fs_by_condition?: Record; + min?: Record; + }, + condition: string, +): number | null { + const v = row.fs_by_condition?.[condition] ?? row.min?.[condition]; + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +/** 구간 안 2D 측점 최소 안전율(없으면 null) */ +export function min2d( + stations: readonly { chainage_m: number; fs_min: number | null }[], + start: number, + end: number, +): number | null { + let best: number | null = null; + for (const s of stations) { + if (s.chainage_m < start - 1e-6 || s.chainage_m > end + 1e-6) continue; + if (s.fs_min === null || !Number.isFinite(s.fs_min)) continue; + if (best === null || s.fs_min < best) best = s.fs_min; + } + return best; +} + +/** 3D/2D 비 — 둘 중 하나라도 없으면 null */ +export function ratio3d2d(f3d: number | null, f2d: number | null): number | null { + return f3d === null || f2d === null || !(f2d > 0) ? null : f3d / f2d; +} diff --git a/E05_StabReport/E05_StabReport_Area.py b/E05_StabReport/E05_StabReport_Area.py new file mode 100644 index 000000000..87633ad7a --- /dev/null +++ b/E05_StabReport/E05_StabReport_Area.py @@ -0,0 +1,130 @@ +"""E05 보고서 s7 광역요약 — E07 광역 자연사면 위험 저장본 → 시트 (PLAN 536-2). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s7. +E07 저장본이 없으면 「광역 해석 전」. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from E02_StabGround.E02_StabGround_Model import load_defaults +from E05_StabReport.E05_StabReport_Cells import UNCONFIRMED +from E07_StabArea.E07_StabArea_Engine import CLASS_KEYS, LEVEL_KEYS +from E07_StabArea.E07_StabArea_Router import RESULT, AreaRequest, load + +LEVEL_NAMES = {"low": "낮음", "mid": "보통", "high": "높음"} + + +def scenario_text(s: dict[str, Any]) -> str: + """시나리오 글 — 화면 시나리오 이름과 같은 말.""" + if s.get("scenario") == "saturated": + return "최악 — 완전 포화(m = 1 · 강우 극한)" + if s.get("scenario") == "sinmap": + return ( + f"표준 — SINMAP 습윤지수 W = min(R·a / (T·sinθ), 1) · " + f"T/R {s['tr_lo']:g}~{s['tr_hi']:g} m" + ) + return f"옛 조건({s.get('scenario')}) — E07 에서 [다시 해석]" + + +def stamp(project_root: Path) -> list[int] | None: + """해석 지문용 — 저장본 크기 · 고친 때.""" + path = Path(project_root) / RESULT + return [path.stat().st_size, path.stat().st_mtime_ns] if path.is_file() else None + + +def read(project_root: Path) -> dict[str, Any] | None: + """저장본에서 보고서에 쓰는 것만(격자 배열 뺌).""" + doc = load(project_root) + if not doc: + return None + return {k: doc[k] for k in ("settings", "computed_at", "stations", "summary")} + + +def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: + cell = dict(sheet["칸"].get(a1) or {}) + cell["값"] = round(value, 6) if isinstance(value, float) else value + if style is not None: + cell["서식"] = style + sheet["칸"][a1] = cell + + +UNCONFIRMED_KEYS = ( # (설정 칸, 글) — 기본값 그대로면 사용자 확정 전 + ("c_root", "뿌리 점착 {c_root:g} kPa"), + ("soil_depth_m", "토층 {soil_depth_m:g} m"), + ("tr_lo", "T/R {tr_lo:g}~{tr_hi:g} m"), + ("level_lo_pct", "측점 문턱 {level_lo_pct:g} % · {level_hi_pct:g} %"), +) +PAIRS = {"tr_lo": "tr_hi", "level_lo_pct": "level_hi_pct"} + + +def _unconfirmed(s: dict[str, Any]) -> str: + """기본값 그대로인 설정 · 물성(E02 붕적토 문헌 추정치) — 사용자 확정 전 한 줄.""" + fields = AreaRequest.model_fields + same = [ + text.format(**s) + for key, text in UNCONFIRMED_KEYS + if all( + k in s and float(s[k]) == float(fields[k].default) for k in (key, PAIRS.get(key, key)) + ) + ] + base = {m["id"]: m for m in load_defaults()["materials"]}.get( + s.get("material_id") or "colluvium" + ) + if base and all( + float(s.get(k, -1)) == float(base[k]) for k in ("gamma_t", "gamma_sat", "c", "phi") + ): + same.insert(0, f"토층 물성 = {base['name']} 문헌 추정치") + return f"{' · '.join(same)} — {UNCONFIRMED}" if same else "없음(모두 사용자 입력)" + + +def fill_area(sheet: dict[str, Any], area: dict[str, Any] | None, labels: dict[float, str]) -> None: + """s7 — 조건 넷 · 등급별 면적 여섯 · 측점 등급별 수 · 보통 · 높음 측점(22 행부터 한 줄씩).""" + if not area: + _set(sheet, "B5", "광역 해석 전 — E07 광역에서 [해석] 뒤 [보고서 갱신]") + return + if "station_levels" not in area["summary"]: + _set(sheet, "B5", "옛 판 저장본 — E07 광역에서 [다시 해석] 뒤 [보고서 갱신]") + return + s, summary = area["settings"], area["summary"] + _set(sheet, "B5", f"중심선 ± {s['band_m']:g} m · 격자 {s['cell_m']:g} m") + _set( + sheet, + "B6", + f"γt {s['gamma_t']:g} · γsat {s['gamma_sat']:g} kN/m³ · c {s['c']:g}~{s['c_hi']:g} kPa · " + f"φ {s['phi']:g}~{s['phi_hi']:g}° · 뿌리 점착 {s['c_root']:g} kPa · " + f"토층 {s['soil_depth_m']:g} m", + ) + _set(sheet, "B7", scenario_text(s)) + _set(sheet, "B8", str(area["computed_at"]).replace("T", " ")) + _set(sheet, "B9", _unconfirmed(s)) + for k in range(1, 7): + _set(sheet, f"C{11 + k}", float(summary["areas_m2"][CLASS_KEYS[k]])) + lo, hi = s["level_lo_pct"], s["level_hi_pct"] + bounds = {"low": f"< {lo:g} %", "mid": f"{lo:g} ~ {hi:g} %", "high": f"≥ {hi:g} %"} + for col, text in zip("EFG", ("측점 등급", "SI<1 셀 비율", "측점 수")): + _set(sheet, f"{col}11", text, 6) + for i, key in enumerate(LEVEL_KEYS): + _set(sheet, f"E{12 + i}", LEVEL_NAMES[key], 7) + _set(sheet, f"F{12 + i}", bounds[key], 7) + _set(sheet, f"G{12 + i}", int(summary["station_levels"][key]), 14) + _set(sheet, "A20", f"3. 위험 측점 — 보통 · 높음 (영향 구역 안 SI < 1 셀 비율 {lo:g} % 이상)") + _set(sheet, "E21", "측점 등급 (SI<1 비율)") + rows = [r for r in area["stations"] if r.get("level") in ("mid", "high")] + template = {col: dict(sheet["칸"].get(f"{col}22") or {}) for col in "ABCDEFG"} + if not rows: + _set(sheet, "A22", "위험 측점 없음") + for i, row in enumerate(rows): + r = 22 + i + for col, cell in template.items(): + sheet["칸"][f"{col}{r}"] = dict(cell) + ch = float(row["chainage_m"]) + _set(sheet, f"A{r}", row.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}") + _set(sheet, f"B{r}", ch) + _set(sheet, f"C{r}", float(row["fs_min"])) + _set(sheet, f"D{r}", float(row["si_min"])) + _set(sheet, f"E{r}", f"{LEVEL_NAMES[row['level']]} ({row['risk_ratio'] * 100:.1f} %)") + _set(sheet, f"F{r}", float(row["risk_area_m2"])) + _set(sheet, f"G{r}", float(row["slope_max_deg"])) diff --git a/E05_StabReport/E05_StabReport_Build.py b/E05_StabReport/E05_StabReport_Build.py new file mode 100644 index 000000000..bf0407150 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Build.py @@ -0,0 +1,494 @@ +"""E05 안정성평가 보고서 빌더(요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3 · 534-5). + +입력 = E02 지반 조건(`ground.json`) · E03 2D 파생 저장본(`stab2d_summary` 가장 최근 · 측점별 +`stab2d_station`) · 대표 단면(E03 자동 + `representative.json` 켬/끔) · E04 3D 저장본 요약 +(있으면) · E07 광역 저장본(있으면) · E03 보강 검토 저장본(있으면 · 없으면 s8 뺌) · +E06 암반 해석 저장본(있으면 · 없으면 s11 뺌) · E03 확률 해석 저장본(있으면 · 없으면 s12 뺌) · +E01 복사본(측점 · 횡단 · 노선). +양식 = `resources/master_template/report/stab_report.json` +칸 자리 = 같은 폴더 `stab_report_slots.md`. + +몫: 사용자(표지 s1 · 개요 s2) = 처음 만들 때만 채움 · +프로그램(목차 s9 · 입력조건 s3 · 측점표 s4 · 대표단면 s5 · 3D요약 s6 · 광역요약 s7 · 보강검토 s8 · +암반검토 s11 · 확률해석 s12 · 부록_절편 s10) = [보고서 갱신] 때 양식에서 통째로 다시 씀. 대표 단면 · 부록 = `_Section` · +목차 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔 = `_Print` · 칸 공용 = `_Cells`. +판정 글 칸 = 엔진 판정 값 + N.G 서식 10 · O.K 서식 11 을 직접 붙임 +(인쇄 HTML · xlsx 는 조건부 서식을 안 먹음). + +storage(작업본 · 초기값 · 그림 · 옛 판) = `E05_StabReport_Store.py`. + +계산 자리 = ③ 서버 단독(값 셈 없음 · 결과 옮겨 적기 · 수식은 공용 Node 엔진). +""" + +from __future__ import annotations + +import hashlib +import json +import re +from datetime import date +from pathlib import Path +from typing import Any + +from common_util.common_util_derived_store import read_derived +from E01_StabRegister import E01_StabRegister_Source as source +from E02_StabGround import E02_StabGround_Model as ground_model +from E03_StabSection import E03_StabSection_View as view2d +from E03_StabSection.E03_StabSection_Router import KIND_STATION, _latest_summary, _overrides +from E04_Stab3D import E04_Stab3D_Run as run3d +from E05_StabReport import E05_StabReport_Area as area +from E05_StabReport import E05_StabReport_Figure as figure +from E05_StabReport import E05_StabReport_Print as printer +from E05_StabReport import E05_StabReport_Prob as prob +from E05_StabReport import E05_StabReport_Reinforce as reinforce +from E05_StabReport import E05_StabReport_Rock as rock +from E05_StabReport import E05_StabReport_Section as section +from E05_StabReport.E05_StabReport_Cells import ( # noqa: F401 — 시험 · 다른 모듈이 build.* 로 씀 + A1, + SIDE_NAMES, + ST_NG, + ST_OK, + _copy_row, + _criterion, + _label_at, + _labels, + _seismic, + _set, + _verdict, + fig_key, + mark, +) + +ROOT = Path(__file__).resolve().parents[1] +FORM_PATH = ROOT / "resources" / "master_template" / "report" / "stab_report.json" +STAGE = "E05_StabReport" +REPORT, META = "report.json", "report_meta.json" +USER_SHEETS = ("s1", "s2") +PROGRAM_SHEETS = ("s9", "s3", "s4", "s5", "s6", "s7", "s8", "s11", "s12", "s10") +REPRESENTATIVE = Path("E03_StabSection") / "representative.json" +BAND_KEY = "fig/3d_band.png" + + +class MissingInput(Exception): + """보고서를 만들 해석 결과가 없음(2D 해석 전).""" + + +# ── 읽기 ───────────────────────────────────────────────────────────────────── +def _read(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def stage_dir(project_root: Path) -> Path: + return Path(project_root) / STAGE + + +def initial_dir(project_root: Path) -> Path: + return Path(project_root) / "initial_snapshot" / STAGE + + +def fig_path(folder: Path, key: str) -> Path: + """그림 열쇠 → 파일(윈도우 못 쓰는 글자 `:` 등은 `_`).""" + name = key.removeprefix("fig/") + return Path(folder) / "fig" / re.sub(r"[^0-9A-Za-z가-힣._+-]", "_", name) + + +def form() -> dict[str, Any]: + return json.loads(FORM_PATH.read_text(encoding="utf-8")) + + +def _ground(project_root: Path) -> dict[str, Any]: + return ( + _read(ground_model.ground_path(project_root)) + or _read(ground_model.initial_path(project_root)) + or ground_model.pick(ground_model.load_defaults()) + ) + + +def _station_result(project_root: Path, fp: str) -> tuple[dict[str, Any] | None, list | None]: + """측점 저장본 — (결과, 해석에 쓴 넓힌 지반선 · 없으면 None).""" + value = (read_derived(Path(project_root), KIND_STATION, fp) or {}).get("value") or {} + return value.get("out"), (value.get("inputs") or {}).get("ground") + + +def _row2d(row: dict[str, Any], method: str) -> dict[str, Any]: + """E03 요약 줄 → 보고서 줄 — 비탈 = 기준 대비 가장 낮은 쪽(fs / 기준) · 판정 = 조건 하나라도 + N.G 면 ng.""" + roles = {s["side"]: s["role"] for s in row.get("sides") or []} + cases = row.get("cases") or {} + worst = min( + ( + (c["fs"] / c["required"], side) + for sides in cases.values() + for side, c in sides.items() + if c.get("fs") is not None and c.get("required") + ), + default=None, + ) + side = worst[1] if worst else None + verdicts = set((row.get("verdict") or {}).values()) + values = [v for v in (row.get("min") or {}).values() if v is not None] + return { + "station_key": row["key"], + "chainage_m": row["chainage_m"], + "fp": row.get("fp"), + "side": roles.get(side), + "fs_by_condition": { + cond: sides[side]["fs"] for cond, sides in cases.items() if side in sides + }, + "fs_min": min(values) if values else None, + "min_by_condition": row.get("min") or {}, + "method": method, + "verdict": "ng" if "ng" in verdicts else "ok" if verdicts == {"ok"} else None, + "representative": row.get("representative") or {}, + } + + +def _result2d(project_root: Path) -> dict[str, Any] | None: + """E03 가장 최근 요약(지금 입력과 달라도 · E03 화면과 같은 판) + 대표 단면 표시.""" + summary, _ = _latest_summary(project_root, "") + if not summary: + return None + rows = [dict(r) for r in summary.get("stations") or []] + view2d.mark_representative(rows, _overrides(project_root)) + method = summary.get("method") or view2d.PRIMARY + return {**summary, "stations": [_row2d(r, method) for r in rows]} + + +def analysis_fingerprint(project_root: Path) -> str: + """해석 지문 — 2D 요약 지문 · 대표 단면 · 지반 조건 · 3D 저장본 · 양식이 같으면 같음 + (가벼운 읽기만).""" + project_root = Path(project_root) + r2, _ = _latest_summary(project_root, "") + folder = run3d.results_dir(project_root) + body = { + "2d": [r2.get("fingerprint"), r2.get("computed_at")] if r2 else None, + "rep": _read(project_root / REPRESENTATIVE), + "ground": _ground(project_root), + "3d": [ + [p.name, p.stat().st_size, p.stat().st_mtime_ns] for p in sorted(folder.glob("*.json")) + ] + if folder.is_dir() + else [], + "area": area.stamp(project_root), + "reinforce": reinforce.stamp(project_root), + "rock": rock.stamp(project_root), + "prob": prob.stamp(project_root), + "form": hashlib.sha1(FORM_PATH.read_bytes()).hexdigest(), + } + text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha1(text.encode("utf-8")).hexdigest() + + +def _representatives(r2: dict[str, Any]) -> list[dict[str, Any]]: + """E03 대표 단면 켬 — 까닭 = 사용자 켬이면 user · 아니면 자동 첫 까닭.""" + out = [] + for s in r2.get("stations") or []: + mark = s["representative"] + if mark.get("on"): + reason = "user" if mark.get("user") else (mark.get("auto") or ["user"])[0] + out.append({"station_key": s["station_key"], "reason": reason, "summary": s}) + return out + + +def gather(project_root: Path) -> dict[str, Any]: + """빌더 입력 한 벌 — 2D 결과가 없으면 `MissingInput`.""" + project_root = Path(project_root) + r2 = _result2d(project_root) + if not r2 or not r2.get("stations"): + raise MissingInput("2D 해석 결과가 없습니다 — E03 에서 [해석] 뒤 만드세요.") + root = source.source_dir(project_root) + db = source.db_snapshot(root) + ground = _ground(project_root) + resolved = {round(float(s["chainage_m"]), 3): s for s in ground_model.resolve_stations(ground)} + reps = _representatives(r2) + for item in reps: + chainage = float(item["summary"]["chainage_m"]) + item["result"], item["ground"] = _station_result(project_root, item["summary"]["fp"]) + try: + item["cross"] = source.cross_section(root, chainage) + except KeyError: + item["cross"] = None + item["layers"] = resolved.get(round(chainage, 3)) + try: + sections3d = run3d.saved_summaries(project_root) + except (OSError, ValueError, KeyError): + sections3d = [] + return { + "project": db.get("project") or {}, + "route_points": sorted(db.get("route_points") or [], key=lambda p: p["sequence_num"]), + "stations": source.stations(root), + "result2d": r2, + "representatives": reps, + "ground": ground, + "sections3d": sorted(sections3d, key=lambda s: s["start_m"]), + "area": area.read(project_root), + "reinforce": reinforce.read(project_root), + "rock": rock.read(project_root), + "prob": prob.read(project_root), + "fingerprint": analysis_fingerprint(project_root), + } + + +# ── 칸 쓰기 ────────────────────────────────────────────────────────────────── +# ── 사용자 몫(처음 한 번) ──────────────────────────────────────────────────── +def fill_cover(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: + """표지 — 사업명 · 노선명(지역 · 임도 종류) · 작성일. 발주처 · 작성자 = 사용자가 씀.""" + project = inputs["project"] + _set(sheet, "C8", project.get("name")) + _set( + sheet, + "C9", + " · ".join(str(v) for v in (project.get("region"), project.get("road_type")) if v), + ) + _set(sheet, "C11", date.today().isoformat()) + + +# ── 프로그램 몫 ────────────────────────────────────────────────────────────── +WATER_TEXT = { + "none": "지하수 없음", + "surface": "지하수위 = 공사 뒤 지표(포화)", + "half_height": "비탈면 높이 ½ 심도까지 포화", +} + + +def _wet_text(water: dict[str, Any], base: dict[str, Any]) -> str: + """우기 지하수 갈래 글(E02 `wet_mode`) · 기본값이면 사용자 확정 전 표기.""" + mode = water.get("wet_mode") or "kds" + if mode == "depth_ratio": + ratio = float(water["depth_ratio"]) + return mark(f"지표 아래 깊이 = 비탈 높이 × {ratio:g}", ratio == float(base["depth_ratio"])) + if mode == "ru": + ru = float(water["ru"]) + return mark(f"간극수압비 r_u = {ru:g}", ru == float(base["ru"])) + kds = ( + f"토사 · 풍화암: {WATER_TEXT.get(water.get('wet_soil'), '-')} · " + f"암반: {WATER_TEXT.get(water.get('wet_rock'), '-')}" + ) + if mode == "line": + return f"사용자 수위선 {len(water.get('lines') or [])} 구간 · 구간 밖 = {kds} (사용자 입력)" + same = all(water.get(k) == base.get(k) for k in ("wet_soil", "wet_rock")) + return mark(f"KDS 기본 — {kds}", same) + + +def fill_conditions(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: + """s3 입력조건.""" + ground = inputs["ground"] + base = ground_model.load_defaults() + base_sf = base["safety_factors"] + _set(sheet, "B5", "한계평형해석 — Bishop 간편법(기본) · Janbu 보정 · Fellenius 비교") + _set(sheet, "B6", "원호(격자 · 진입-탈출) + 비원호(꺾은선) · 측점마다 좌 · 우 비탈 따로") + _set( + sheet, + "B7", + "Hungr 3D Bishop(+Janbu 보정) · 구 · 타원체 탐색 — 「3D요약」 시트" + if inputs["sections3d"] + else "3D 해석 전", + ) + _set(sheet, "B8", "KDS 11 70 05 비탈면 기준 안전율") + for r, case in ((12, "dry"), (13, "wet"), (14, "seismic")): + if case == "seismic" and not _seismic(ground): + _set(sheet, f"B{r}", "-") + _set(sheet, f"C{r}", "지진 검토 안 함") + continue + sf = ground["safety_factors"][case] + sf_base = base_sf.get(case) or {} + same = all(float(sf_base.get(k, -1)) == float(sf[k]) for k in ("cut", "fill")) + _set(sheet, f"B{r}", _criterion(ground, case)) + text = f"깎기 {sf['cut']:.2f} · 쌓기 {sf['fill']:.2f} — 표 값 = 엄한 쪽 · KDS 11 70 05" + _set(sheet, f"C{r}", mark(text, same)) + water = ground.get("groundwater") or {} + _set(sheet, "B17", WATER_TEXT.get(water.get("dry"), "-")) + _set(sheet, "B18", _wet_text(water, base["groundwater"])) + seismic = ground.get("seismic") or {} + _set( + sheet, + "B19", + f"건기 수위 + 수평지진계수 kh = {float(seismic.get('kh') or 0):.2f}" + if _seismic(ground) + else "적용 안 함", + ) + load = float(ground.get("road_surcharge_kpa") or 0) + _set(sheet, "B22", load) + default_load = float(base["road_surcharge_kpa"]) + _set(sheet, "D22", mark("옹벽 배면 상재하중 준용", load == default_load)) + defaults = {m["id"]: m for m in base["materials"]} + names = {m["id"]: m["name"] for m in ground.get("materials") or []} + mapped = set((ground.get("label_map") or {}).values()) # 지반 라벨에 이은 지층만 「전체」 + rows: list[tuple[dict[str, Any], str]] = [ + (m, "전체" if m["id"] in mapped else "-") for m in ground.get("materials") or [] + ] + for zone in ground.get("zones") or []: + span = f"{float(zone['from_m']):g} ~ {float(zone['to_m']):g} m" + for layer, where in (("soil", "위"), ("rock", "아래")): + mid = zone.get(layer) + match = next((m for m in ground["materials"] if m["id"] == mid), None) + if match: + depth = zone.get("soil_thickness_m") if layer == "soil" else None + more = f" · 두께 {float(depth):g} m" if depth is not None else "" + rows.append((match, f"{span} {where} 지층{more}")) + if zone.get("soil_thickness_m") is not None and not (zone.get("soil") or zone.get("rock")): + rows.append(({"name": "토층 두께"}, f"{span} · {zone['soil_thickness_m']:g} m")) + for i, (m, span) in enumerate(rows): + r = 26 + i + _copy_row(sheet, 26, r, "ABCDEFG") + _set(sheet, f"A{r}", m.get("name") or names.get(m.get("id"), "")) + _set(sheet, f"B{r}", span) + for col, field in zip("CDEF", ground_model.MATERIAL_FIELDS): + _set(sheet, f"{col}{r}", float(m[field]) if field in m else None) + base = defaults.get(m.get("id")) + same = base and all( + float(base[f]) == float(m.get(f, -1)) for f in ground_model.MATERIAL_FIELDS + ) + _set( + sheet, + f"G{r}", + ("문헌 추정치 · 사용자 확정 전" if same else "사용자 입력") if "c" in m else None, + ) + + +def fill_stations(sheet: dict[str, Any], inputs: dict[str, Any]) -> int: + """s4 측점표 — 측점 하나 = 한 줄(6 행부터) · N.G 수.""" + ground = inputs["ground"] + seismic = _seismic(ground) + labels = _labels(inputs["stations"]) + rows = sorted(inputs["result2d"]["stations"], key=lambda s: float(s["chainage_m"])) + ng = 0 + for i, st in enumerate(rows): + r = 6 + i + _copy_row(sheet, 6, r, "ABCDEFGHIJK") + side = st.get("side") + fs = st.get("fs_by_condition") or {} + _set(sheet, f"A{r}", _label_at(labels, float(st["chainage_m"]))) + _set(sheet, f"B{r}", float(st["chainage_m"])) + _set(sheet, f"C{r}", SIDE_NAMES.get(side, side)) + for col, crit, case in (("D", "G", "dry"), ("E", "H", "wet"), ("F", "I", "seismic")): + use = case != "seismic" or seismic + value = fs.get(case) if use else None + _set(sheet, f"{col}{r}", float(value) if value is not None else None) + _set(sheet, f"{crit}{r}", _criterion(ground, case, side) if use else None) + verdict = st.get("verdict") + ok = None if verdict not in ("ok", "ng") else verdict == "ok" + ng += ok is False + _verdict(sheet, f"J{r}", ok) + _set(sheet, f"K{r}", None if ok is None else "-" if ok else "대책 검토") + return ng + + +def fill_3d(sheet: dict[str, Any], inputs: dict[str, Any]) -> tuple[str, str]: + """s6 3D요약 — 구간 하나 = 한 줄(20 행부터) · 평면 위험 띠 그림 일감.""" + ground = inputs["ground"] + labels = _labels(inputs["stations"]) + rows2d = inputs["result2d"]["stations"] + sections = [] + for i, sec in enumerate(inputs["sections3d"]): + r = 20 + i + _copy_row(sheet, 20, r, "ABCDEGH") + start, end = float(sec["start_m"]), float(sec["end_m"]) + cond = sec.get("condition") or "dry" # 2D 는 3D 와 같은 조건끼리 견줌 + inside = [ + float(s["min_by_condition"][cond]) + for s in rows2d + if s["min_by_condition"].get(cond) is not None + and start - 1e-6 <= float(s["chainage_m"]) <= end + 1e-6 + ] + f3 = float(sec["f_min"]) + if sec.get("verdict") in ("ok", "ng"): # 판정 = E04 엔진 값(기준안전율도 E04) + ok = sec["verdict"] == "ok" + else: + ok = f3 >= _criterion(ground, cond) - 1e-9 + _set(sheet, f"A{r}", f"{i + 1}구간" + (" (미리보기)" if sec.get("preview") else "")) + _set(sheet, f"B{r}", _label_at(labels, start)) + _set(sheet, f"C{r}", _label_at(labels, end)) + _set(sheet, f"D{r}", f3) + _set(sheet, f"E{r}", min(inside) if inside else None) + sheet["칸"][f"F{r}"] = { + "식": f'IF(E{r}>0,D{r}/E{r},"")', + "서식": sheet["칸"]["F20"]["서식"], + } + _set(sheet, f"G{r}", float(sec.get("volume") or 0)) + _verdict(sheet, f"H{r}", ok) + sections.append({"start_m": start, "end_m": end, "ok": ok}) + if not inputs["sections3d"]: + _set(sheet, "A20", "3D 해석 전") + xy = {round(float(s["chainage_m"]), 3): s for s in inputs["stations"]} + marks = [] + for st in sorted(rows2d, key=lambda s: float(s["chainage_m"])): + at = xy.get(round(float(st["chainage_m"]), 3)) + if at and at.get("center_x") is not None: + verdict = st.get("verdict") + marks.append( + { + "x": float(at["center_x"]), + "y": float(at["center_y"]), + "ok": None if verdict not in ("ok", "ng") else verdict == "ok", + "label": at.get("label"), + } + ) + line = [ + (float(p["model_x"]), float(p["model_y"]), float(p["chainage_m"])) + for p in inputs["route_points"] + ] + svg = figure.band_svg( + title="평면 위험 띠 — 2D 측점 판정 · 3D 구간", line=line, stations=marks, sections=sections + ) + return BAND_KEY, svg + + +def build_program(book: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]: + """양식 사본 `book` 의 프로그램 몫 시트를 채움 · 그림 일감.""" + by = {s["id"]: s for s in book["시트"]} + fill_conditions(by["s3"], inputs) + n = len(inputs["result2d"]["stations"]) + fill_stations(by["s4"], inputs) + printer.balance_breaks(by["s4"], book, 6, 5 + n) + jobs = section.fill_sections(by["s5"], inputs) + section.fill_slices(by["s10"], inputs) + jobs.append(fill_3d(by["s6"], inputs)) + area.fill_area(by["s7"], inputs["area"], _labels(inputs["stations"])) + risky = [ + r for r in (inputs["area"] or {}).get("stations") or [] if r.get("level") in ("mid", "high") + ] + printer.balance_breaks(by["s7"], book, 22, 21 + len(risky)) + if inputs.get("reinforce"): + reinforce.fill_reinforce(by["s8"], inputs["reinforce"], _labels(inputs["stations"])) + else: # 보강 검토가 없으면 시트를 뺌 + book["시트"] = [s for s in book["시트"] if s["id"] != "s8"] + if inputs.get("rock"): + rock.fill_rock(by["s11"], inputs["rock"], _labels(inputs["stations"])) + else: # 암반 해석이 없으면 시트를 뺌 + book["시트"] = [s for s in book["시트"] if s["id"] != "s11"] + if inputs.get("prob"): + prob.fill_prob(by["s12"], inputs["prob"], _labels(inputs["stations"])) + else: # 확률 해석이 없으면 시트를 뺌 + book["시트"] = [s for s in book["시트"] if s["id"] != "s12"] + printer.fill_toc(by["s9"], book, None) # 쪽 번호 = 저장 전 `printer.number_toc` + return jobs + + +def sheet_sign(sheet: dict[str, Any]) -> str: + """시트 서명 — 칸 값 · 식 · 서식 · 병합 · 그림 · 행열 크기 + (빈 칸 · 빈 꾸밈은 뺌) · 덮임 알림용.""" + cells = { + a1: [c.get("값"), c.get("식"), c.get("서식", 0)] + for a1, c in (sheet.get("칸") or {}).items() + if c.get("값") is not None or c.get("식") or c.get("서식") + } + body = { + "칸": cells, + "병합": sorted(sheet.get("병합") or []), + "그림": sheet.get("그림") or [], + "행": {k: v for k, v in (sheet.get("행") or {}).items() if v}, + "열": {k: v for k, v in (sheet.get("열") or {}).items() if v}, + } + text = json.dumps(_numbers(body), ensure_ascii=False, sort_keys=True) + return hashlib.sha1(text.encode("utf-8")).hexdigest() + + +def _numbers(value: Any) -> Any: + """수는 float 로 — 화면(JS)을 거치면 10.0 이 10 으로 옴(같은 값을 다르다고 보지 않게).""" + if isinstance(value, dict): + return {k: _numbers(v) for k, v in value.items()} + if isinstance(value, list): + return [_numbers(v) for v in value] + if isinstance(value, int | float) and not isinstance(value, bool): + return round(float(value), 9) + return value diff --git a/E05_StabReport/E05_StabReport_Cells.py b/E05_StabReport/E05_StabReport_Cells.py new file mode 100644 index 000000000..fedf4c4ec --- /dev/null +++ b/E05_StabReport/E05_StabReport_Cells.py @@ -0,0 +1,98 @@ +"""E05 보고서 칸 쓰기 공용 — 이름표 · 서식 번호 · 칸 값 · 판정 · 기준 안전율 · 측점 이름. + +(PLAN 534-3 · 534-5) 빌더 · 대표 단면(`_Section`) · 인쇄(`_Print`)가 같이 씀. +""" + +from __future__ import annotations + +import re +from typing import Any + +CASES = ("dry", "wet", "seismic") +SIDE_NAMES = {"cut": "깎기", "fill": "쌓기"} +METHOD_NAMES = { + "bishop": "Bishop", + "janbu": "Janbu 보정", + "janbu_corrected": "Janbu 보정", + "ordinary": "Fellenius", + "spencer": "Spencer", +} +REASONS = { + "fill_height": "쌓기 높이 10 m 이상", + "cut_height": "깎기 높이 15 m 이상", + "lowest": "최소 안전율 하위", + "user": "사용자 지정", +} +GROUND_NAMES = {"soil": "토사", "ripping_rock": "리핑암", "blasting_rock": "발파암", "fill": "성토"} +ST_TEXT, ST_BODY, ST_FS, ST_NG, ST_OK, ST_NUM1, ST_PROP, ST_CHAIN = 5, 7, 8, 10, 11, 13, 16, 17 +A1 = re.compile(r"^([A-Z]+)(\d+)$") + + +def fig_key(station_key: str) -> str: + return f"fig/{station_key}.png" + + +UNCONFIRMED = "기본값 · 사용자 확정 전" + + +def mark(text: str, default: bool) -> str: + """사용자 확정 전 표기 한 규칙 — 기본값 그대로 = 「(기본값 · 사용자 확정 전)」 · 아니면 + 「(사용자 입력)」.""" + return f"{text} ({UNCONFIRMED if default else '사용자 입력'})" + + +def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: + """값(None = 지움) · 서식(None = 양식 그대로).""" + cell = dict(sheet["칸"].get(a1) or {}) + cell.pop("식", None) + if value is None or value == "": + cell.pop("값", None) + else: + cell["값"] = round(value, 6) if isinstance(value, float) else value + if style is not None: + cell["서식"] = style + if cell: + sheet["칸"][a1] = cell + else: + sheet["칸"].pop(a1, None) + + +def _copy_row(sheet: dict[str, Any], src: int, dst: int, cols: str) -> None: + for col in cols: + cell = sheet["칸"].get(f"{col}{src}") + if cell and dst != src: + sheet["칸"][f"{col}{dst}"] = {"서식": cell["서식"]} if "서식" in cell else {} + + +def _shift(a1: str, dr: int) -> str: + m = A1.match(a1) + return f"{m.group(1)}{int(m.group(2)) + dr}" + + +def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None: + if ok is None: + _set(sheet, a1, None) + else: + _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG) + + +def _criterion(ground: dict[str, Any], case: str, side: str | None = None) -> float: + sf = (ground.get("safety_factors") or {}).get(case) or {} + if side in ("cut", "fill"): + return float(sf[side]) + return max(float(sf["cut"]), float(sf["fill"])) # 표 하나 = 엄한 쪽 + + +def _seismic(ground: dict[str, Any]) -> bool: + return bool((ground.get("seismic") or {}).get("enabled")) + + +def _labels(stations: list[dict[str, Any]]) -> dict[float, str]: + return {round(float(s["chainage_m"]), 3): s.get("label") or "" for s in stations} + + +def _label_at(labels: dict[float, str], chainage: float) -> str: + if not labels: + return f"{chainage:.2f}" + near = min(labels, key=lambda c: abs(c - chainage)) + return labels[near] or f"{chainage:.2f}" diff --git a/E05_StabReport/E05_StabReport_Figure.py b/E05_StabReport/E05_StabReport_Figure.py new file mode 100644 index 000000000..36816d8d6 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Figure.py @@ -0,0 +1,386 @@ +"""E05 보고서 그림 — 서버 SVG(요구 347-5 · PLAN 534-3). PNG 는 `common_util_svg_png` 가 만듦. + + section_svg(…) → 대표 단면(712 × 640) — 원지반 · 공사 뒤 지표 · 지층(무늬) · 성토(점) · + 우기 지하수위 · 조건별 임계 활동면(선 모양) · 노폭 · 비탈 높이 치수 · + 표고 · 거리 눈금 · 축척 막대 · 안전율 상자 · 지층 범례 + band_svg(…) → 평면 위험 띠(684 × 320) — 노선 · 측점 2D 판정 색 · 3D 구간 띠 + +좌표 = B06 횡단 offset(좌 +) · 화면 왼쪽 = +offset +(B06 횡단도 관례 · `B06_Section_UI_Cross_View.ts`). +값 셈 없음 — 해석 결과(E03 · E04 저장본)를 그리기만. +""" + +from __future__ import annotations + +import math +from html import escape +from typing import Any + +SECTION_W, SECTION_H = 712, 640 +BAND_W, BAND_H = 684, 320 +OK, NG, MUTED, INK = "#2E7D32", "#C00000", "#9E9E9E", "#222222" +CONDITION_COLORS = {"dry": "#D32F2F", "wet": "#1565C0", "seismic": "#7B1FA2"} +CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SLIP_DASH = {"wet": "7 4", "seismic": "2 3"} # 건기 · 우기 임계면이 겹쳐도 둘 다 보이게 +FONT = "font-family='Malgun Gothic, sans-serif'" + + +def _nice_step(span: float, count: int) -> float: + raw = max(span, 1e-6) / max(count, 1) + base = 10 ** math.floor(math.log10(raw)) + return next(m * base for m in (1, 2, 5, 10) if m * base >= raw) + + +def _poly(points: list[tuple[float, float]], **attrs: str) -> str: + if len(points) < 2: + return "" + d = " ".join(f"{x:.1f},{y:.1f}" for x, y in points) + extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items()) + return f"" + + +def _text(x: float, y: float, text: str, size: int = 11, anchor: str = "middle", **attrs) -> str: + extra = " ".join(f"{k.replace('_', '-')}='{v}'" for k, v in attrs.items()) + return ( + f"{escape(text)}" + ) + + +def _interp(line: list[tuple[float, float]], x: float) -> float | None: + for (x0, z0), (x1, z1) in zip(line, line[1:]): + if min(x0, x1) - 1e-9 <= x <= max(x0, x1) + 1e-9: + return z0 if x1 == x0 else z0 + (z1 - z0) * (x - x0) / (x1 - x0) + return None + + +def _legend_rows(items: list[tuple[str, str]], width: float, size: int) -> list[list[int]]: + """범례 항목 [(종류, 글)] → 줄마다 항목 번호(폭을 넘으면 다음 줄).""" + rows: list[list[int]] = [[]] + used = 0.0 + for i, (_, label) in enumerate(items): + w = 30 + _text_w(label, size) + if rows[-1] and used + w > width: + rows.append([]) + used = 0.0 + rows[-1].append(i) + used += w + return rows + + +def _text_w(text: str, size: int) -> float: + """글 폭 근사(px) — 한글 · 기호 = 글자 크기 · 그 밖 = 0.6 배.""" + return sum(size if ord(ch) > 0x2000 else 0.6 * size for ch in text) + + +PATTERNS = ( + "" + "" + "" + "" + "" + "" +) +STRATA_FILL = {"soil": "#F3E9D8", "rock": "url(#rockHatch)", "fill": "url(#fillDots)"} + + +def section_svg( + *, + title: str, + ground: list[tuple[float, float]], + surface: list[tuple[float, float]], + rock: list[tuple[float, float]] | None, + slips: list[dict[str, Any]], + road: tuple[float, float] | None, + heights: list[dict[str, Any]], + wet: bool, + strata: list[dict[str, str]] | None = None, + fs_box: list[dict[str, Any]] | None = None, +) -> str: + """대표 단면 SVG(흑백 인쇄에도 읽힘 — 임계면 = 선 모양 · 지층 = 무늬). + + ground · surface · rock = [(offset, z)] · slips = [{condition, F, points}] · + road = (오른 끝 offset, 왼 끝 offset) · heights = [{x, z0, z1, label?}] 비탈 높이 치수 · + strata = [{kind: soil | rock | fill, text}] 지층 범례 · fs_box = [{condition, F, required, ok}] + 안전율 상자 · title = 빈 글이면 안 씀(보고서 쪽 제목이 있음). + """ + pad_l, pad_r, pad_t, pad_b = 58, 18, 30 if title else 22, 88 + pts = [*ground, *surface, *(p for s in slips for p in s["points"])] + if not pts: + return _empty(SECTION_W, SECTION_H, title or "대표 단면") + xs = [p[0] for p in pts] + zs = [p[1] for p in pts] + x_lo, x_hi = min(xs), max(xs) + z_lo, z_hi = min(zs), max(zs) + z_lo -= 0.08 * (z_hi - z_lo + 1) + z_hi += 0.12 * (z_hi - z_lo + 1) + w, h = SECTION_W - pad_l - pad_r, SECTION_H - pad_t - pad_b + scale = min(w / max(x_hi - x_lo, 1e-6), h / max(z_hi - z_lo, 1e-6)) # 가로 · 세로 같은 축척 + cx = pad_l + (w - (x_hi - x_lo) * scale) / 2 + cz = pad_t + (h - (z_hi - z_lo) * scale) / 2 + + def sx(x: float) -> float: + return cx + (x_hi - x) * scale # +offset = 화면 왼쪽 + + def sz(z: float) -> float: + return cz + (z_hi - z) * scale + + def screen(line: list[tuple[float, float]]) -> list[tuple[float, float]]: + return [(sx(x), sz(z)) for x, z in line] + + def area(line: list[tuple[float, float]], y: float) -> str: + """선과 y 사이를 닫은 다각형 점 글.""" + body = screen(line) + return " ".join(f"{a:.1f},{b:.1f}" for a, b in [*body, (body[-1][0], y), (body[0][0], y)]) + + bottom, top = sz(z_lo), pad_t + out = [ + f"", + f"{PATTERNS}", + ] + if surface: + out.append(f"") + if ground: + out.append(f"") + out += ["", f""] + if title: + out.append(_text(SECTION_W / 2, 20, title, 13, font_weight="bold", fill=INK)) + # 눈금(표고 · 거리) + step_z = _nice_step(z_hi - z_lo, 6) + z = math.ceil(z_lo / step_z) * step_z + while z <= z_hi: + y = sz(z) + out.append( + f"" + ) + out.append(_text(pad_l - 6, y + 4, f"{z:g}", 11, "end", fill="#444444")) + z += step_z + step_x = _nice_step(x_hi - x_lo, 8) + x = math.ceil(x_lo / step_x) * step_x + base_y = SECTION_H - pad_b + 15 + while x <= x_hi: + out.append(_text(sx(x), base_y, f"{x:g}", 11, fill="#444444")) + x += step_x + out.append(_text(pad_l - 6, pad_t - 8, "EL.(m)", 10, "end", fill="#444444")) + out.append( + _text(SECTION_W - pad_r, base_y + 15, "offset (m) · 좌 +", 10, "end", fill="#444444") + ) + # 축척 막대(가로 = 세로) + bar = _nice_step((x_hi - x_lo) / 5, 1) + bx, by, half = pad_l + 4, base_y + 12, bar * scale / 2 + out.append( + f"" + f"" + ) + out.append(_text(bx + 2 * half + 6, by, f"{bar:g} m · 가로 = 세로 축척", 10, "start", fill=INK)) + # 지반 · 지층 — 공사 뒤 지표 아래 = 몸체 · 원지반 위 = 성토(점) · 암(빗금) + if surface: + out.append(f"") + if ground: + out.append( + f"" + ) + if rock: + clip = " clip-path='url(#below)'" if surface else "" + out.append(f"") + out.append(_poly(screen(rock), stroke="#6D4C41", stroke_width="1", stroke_dasharray="6 3")) + out.append(_poly(screen(ground), stroke="#6D4C41", stroke_width="1.3", stroke_dasharray="3 2")) + out.append(_poly(screen(surface), stroke=INK, stroke_width="2.2")) + if wet and surface: # 우기 지하수위 = 공사 뒤 지표(KDS 11 70 05 표 4.3-3) — ▽ 표시 + for frac in (0.2, 0.8): + px, pz = surface[int(frac * (len(surface) - 1))] + a, b = sx(px), sz(pz) - 4 + out.append( + f"" + ) + # 임계 활동면 + for slip in slips: + color = CONDITION_COLORS.get(slip["condition"], NG) + dash = SLIP_DASH.get(slip["condition"]) + extra = {"stroke_dasharray": dash} if dash else {} + out.append(_poly(screen(slip["points"]), stroke=color, stroke_width="2.6", **extra)) + # 치수 — 노폭 · 비탈 높이 + if road: + r_off, l_off = road + zr, zl = _interp(surface, r_off), _interp(surface, l_off) + if zr is not None and zl is not None: + y = min(sz(zr), sz(zl)) - 18 + a, b = sx(l_off), sx(r_off) + out.append( + f"" + ) + out.append(_text((a + b) / 2, y - 4, f"노폭 {abs(l_off - r_off):.2f} m", 11, fill=INK)) + for dim in heights: + if abs(dim["z1"] - dim["z0"]) < 0.05: + continue + a = sx(dim["x"]) + y0, y1 = sz(dim["z0"]), sz(dim["z1"]) + out.append( + f"" + ) + label = dim.get("label") or f"H {abs(dim['z1'] - dim['z0']):.2f} m" + # 화면 왼쪽 치수 = 글은 더 왼쪽(비탈과 안 겹침) · 그림 밖으로 나가면 반대쪽 + left = a < SECTION_W / 2 + if not left and a + 4 + _text_w(label, 11) > SECTION_W - 2: + left = True + if left and a - 4 - _text_w(label, 11) < 2: + left = False + anchor = "end" if left else "start" + out.append(_text(a - 4 if left else a + 4, (y0 + y1) / 2, label, 11, anchor, fill=INK)) + if fs_box: + out.append(_fs_box(fs_box, surface, sx, sz, pad_l, pad_t, SECTION_W - pad_r)) + # 범례 — 선 · 표시 · 지층 무늬(폭을 넘으면 다음 줄) + items: list[tuple[str, str]] = [("#6D4C41|3 2", "원지반"), (f"{INK}|", "공사 뒤 지표")] + if rock: + items.append(("#6D4C41|6 3", "지층 경계")) + for slip in slips: + name = CONDITION_NAMES.get(slip["condition"], slip["condition"]) + f = slip.get("F") + color = CONDITION_COLORS.get(slip["condition"], NG) + label = f"{name} 임계면 Fs {f:.2f}" if f else name + items.append((f"{color}|{SLIP_DASH.get(slip['condition'], '')}", label)) + if wet: + items.append(("▽", "우기 지하수위 = 지표")) + for layer in strata or []: + items.append((f"@{layer['kind']}", layer["text"])) + ly = SECTION_H - 46 + for row in _legend_rows(items, SECTION_W - pad_l - pad_r, 11): + lx = float(pad_l) + for i in row: + kind, label = items[i] + if kind.startswith("@"): + out.append( + f"" + ) + elif kind == "▽": + out.append( + f"" + ) + else: + color, dash = kind.split("|") + dash_attr = f" stroke-dasharray='{dash}'" if dash else "" + out.append( + f"" + ) + out.append(_text(lx + 27, ly + 1, label, 11, "start", fill=INK)) + lx += 30 + _text_w(label, 11) + ly += 17 + out.append("") + return "".join(out) + + +def _fs_box(rows, surface, sx, sz, left: float, top: float, right: float) -> str: + """안전율 상자 — 하늘 쪽(지표가 낮은 끝) 위 모서리 · 조건 · Fs · 기준 · 판정.""" + width, line = 236, 19 + height = 24 + line * len(rows) + # surface = offset 오름차순 · 화면 왼끝 = 큰 offset = 끝 점 + low_left = bool(surface) and sz(surface[-1][1]) > sz(surface[0][1]) + x = left + 6 if low_left else right - width - 6 + y = top + 6 + out = [ + f"", + f"", + ] + for cx, head, anchor in ((8, "조건", "start"), (78, "Fs", "end"), (132, "기준", "end")): + out.append(_text(x + cx, y + 16, head, 11, anchor, font_weight="bold", fill=INK)) + out.append(_text(x + 196, y + 16, "판정", 11, font_weight="bold", fill=INK)) + for k, r in enumerate(rows): + yy = y + 22 + line * (k + 1) - 5 + name = CONDITION_NAMES.get(r["condition"], r["condition"]) + f = "-" if r.get("F") is None else f"{r['F']:.2f}" + verdict = "-" if r.get("ok") is None else "O.K" if r["ok"] else "N.G" + color = INK if r.get("ok") is None else OK if r["ok"] else NG + out.append(_text(x + 8, yy, name, 11, "start", fill=INK)) + out.append(_text(x + 78, yy, f, 11, "end", font_weight="bold", fill=INK)) + out.append(_text(x + 132, yy, f"≥ {r['required']:.2f}", 11, "end", fill=INK)) + out.append(_text(x + 196, yy, verdict, 11, font_weight="bold", fill=color)) + return "".join(out) + + +def band_svg( + *, + title: str, + line: list[tuple[float, float, float]], + stations: list[dict[str, Any]], + sections: list[dict[str, Any]], +) -> str: + """평면 위험 띠 SVG. + + line = [(x, y, 누가)] 노선 · stations = [{x, y, ok: bool | None, label}] 2D 판정 · + sections = [{start_m, end_m, ok: bool}] 3D 구간(노선 위 굵은 띠). + """ + pad = 24 + xy = [(p[0], p[1]) for p in line] or [(s["x"], s["y"]) for s in stations] + if not xy: + return _empty(BAND_W, BAND_H, title) + xs = [p[0] for p in xy] + ys = [p[1] for p in xy] + w, h = BAND_W - 2 * pad, BAND_H - 2 * pad - 40 + scale = min(w / max(max(xs) - min(xs), 1e-6), h / max(max(ys) - min(ys), 1e-6)) + ox = pad + (w - (max(xs) - min(xs)) * scale) / 2 + oy = pad + 22 + (h - (max(ys) - min(ys)) * scale) / 2 + + def sp(x: float, y: float) -> tuple[float, float]: + return ox + (x - min(xs)) * scale, oy + (max(ys) - y) * scale # 북 = 위 + + out = [ + f"", + f"", + _text(BAND_W / 2, 18, title, 13, font_weight="bold", fill=INK), + ] + out.append(_poly([sp(x, y) for x, y in xy], stroke="#BDBDBD", stroke_width="3")) + for sec in sections: + part = [sp(x, y) for x, y, ch in line if sec["start_m"] - 1e-6 <= ch <= sec["end_m"] + 1e-6] + color = OK if sec["ok"] else NG + out.append(_poly(part, stroke=color, stroke_width="12", stroke_opacity="0.35")) + for st in stations: + a, b = sp(st["x"], st["y"]) + color = MUTED if st["ok"] is None else OK if st["ok"] else NG + out.append(f"") + for st in (stations[0], stations[-1]) if stations else (): + a, b = sp(st["x"], st["y"]) + out.append(_text(a, b - 7, st["label"] or "", 10, fill=INK)) + lx, ly = pad, BAND_H - 14 + for color, label, width in ( + (OK, "2D O.K", 0), + (NG, "2D N.G", 0), + (OK, "3D 구간 O.K", 12), + (NG, "3D 구간 N.G", 12), + ): + if width: + out.append( + f"" + ) + else: + out.append(f"") + out.append(_text(lx + 26, ly, label, 10, "start", fill=INK)) + lx += 120 + if not sections: + out.append(_text(BAND_W - pad, ly, "3D 해석 전", 10, "end", fill=MUTED)) + out.append("") + return "".join(out) + + +def _empty(width: int, height: int, title: str) -> str: + return ( + f"" + f"" + + _text(width / 2, height / 2, f"{title} — 자료 없음", 13, fill=MUTED) + + "" + ) diff --git a/E05_StabReport/E05_StabReport_Print.py b/E05_StabReport/E05_StabReport_Print.py new file mode 100644 index 000000000..d2dfe7603 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Print.py @@ -0,0 +1,231 @@ +"""E05 보고서 인쇄 — 목차 · 쪽 번호 · 머리말/꼬리말 · 긴 표 쪽 나눔(요구 347-5 · 351 · PLAN 534-5). + + fill_toc(sheet, book, pages) 목차 줄(장 · 내용 · 쪽) — 쪽 없으면 「-」 + number_toc(book) 시트별 쪽 수를 재서 목차 쪽 번호를 채움(브라우저 한 번) + report_pdf(book, figures) 표지(쪽 번호 없음) + 본문(머리말 사업명 · 꼬리말 n / N) 한 벌 + balance_breaks(sheet, book, first, last) + 긴 표 쪽 나눔 — 쪽마다 고르게(마지막 쪽에 한두 줄만 남지 않게) + +쪽 번호 = 표지를 뺀 본문 차례(목차 = 1 쪽). 쪽 수 = 시트 하나씩 인쇄 HTML → PDF 쪽 수 +(시트 경계 = 늘 새 쪽 · `spreadsheet_print_html.ts`). 쪽 재기 · PDF = 공용 PDF 엔진(헤드리스). +""" + +from __future__ import annotations + +import io +import math +from html import escape +from typing import Any + +from common_util.common_util_print_pdf import merge_pdfs, render_htmls_to_pdfs +from common_util.common_util_spreadsheet import print_htmls +from E05_StabReport.E05_StabReport_Cells import A1, _set + +COVER, TOC = "s1", "s9" +TOC_FIRST = 4 # 목차 줄 = 4 행부터(4 행 서식) +TITLES = { + "s2": "개요 및 판정 요약", + "s3": "입력 조건 — 해석 방법 · 기준 안전율 · 지하수 · 하중 · 물성치", + "s4": "측점별 안정성 판정 일람표", + "s5": "대표 단면 해석 결과 — 단면도 · 임계 활동면 · 안전율", + "s6": "3D 해석 요약", + "s7": "광역 자연사면 위험 요약", + "s8": "보강 검토 — 비탈 완화 · 소단 · 벽 외적 안정", + "s11": "암반 비탈면 검토 — 평사투영 · 평면 · 쐐기 안전율", + "s12": "확률 해석 · 민감도 — 파괴확률 · 신뢰도 지수", + "s10": "대표 단면 절편 계산표", +} +APPENDIX = {"s10"} +REPORT_NAME = "비탈면 안정성 검토 보고서" +FONT = "font-family:'Malgun Gothic','맑은 고딕',sans-serif" +PAPER_MM = {"A4": (210, 297), "A3": (297, 420)} +PX_MM = 96 / 25.4 + + +def _row(a1: str) -> int: + return int(A1.match(a1.split(":")[0]).group(2)) + + +def _visible(book: dict[str, Any]) -> list[dict[str, Any]]: + return [s for s in book["시트"] if not s.get("숨김")] + + +def _start_pages(book: dict[str, Any], pages: dict[str, int]) -> dict[str, int]: + """시트 id → 본문 시작 쪽(표지 뺌 · 첫 본문 시트 = 1).""" + out, at = {}, 1 + for s in _visible(book): + if s["id"] == COVER: + continue + out[s["id"]] = at + at += pages.get(s["id"], 0) + return out + + +def fill_toc(sheet: dict[str, Any], book: dict[str, Any], pages: dict[str, int] | None) -> None: + """목차 — 보이는 본문 시트(표지 · 목차 뺌)마다 한 줄 · 장 번호 · 부록 · 시작 쪽.""" + tpl = {c: dict(sheet["칸"].get(f"{c}{TOC_FIRST}") or {}) for c in "ABC"} + for a1 in [a for a in sheet["칸"] if _row(a) >= TOC_FIRST]: + cell = sheet["칸"][a1] + if _row(a1) == TOC_FIRST: # 양식 줄 = 서식만 남김 + cell.pop("값", None) + else: + del sheet["칸"][a1] + height = (sheet.get("행") or {}).get(str(TOC_FIRST)) + start = _start_pages(book, pages) if pages else {} + chapter = 0 + for i, s in enumerate(x for x in _visible(book) if x["id"] not in (COVER, TOC)): + r = TOC_FIRST + i + for c, cell in tpl.items(): + sheet["칸"][f"{c}{r}"] = {k: v for k, v in cell.items() if k != "값"} + if height: + sheet["행"][str(r)] = dict(height) + if s["id"] in APPENDIX: + mark = "부록" + else: + chapter += 1 + mark = f"{chapter}." + _set(sheet, f"A{r}", mark) + _set(sheet, f"B{r}", TITLES.get(s["id"], s["이름"])) + _set(sheet, f"C{r}", start.get(s["id"], "-")) + + +def _only(book: dict[str, Any], keep: set[str], hide: bool = False) -> dict[str, Any]: + """`keep` 시트만 보이는(hide = 참이면 `keep` 만 숨긴) 얕은 사본 · 브라우저 계산값 뺌.""" + sheets = [{**s, "숨김": True} if (s["id"] in keep) == hide else s for s in book["시트"]] + return {**{k: v for k, v in book.items() if k != "계산값"}, "시트": sheets} + + +def _figures(book: dict[str, Any], ids: set[str], figures: dict[str, bytes]) -> dict[str, bytes]: + keys = {f["열쇠"] for s in book["시트"] if s["id"] in ids for f in s.get("그림") or []} + return {k: v for k, v in figures.items() if k in keys} + + +def sheet_pages(book: dict[str, Any]) -> dict[str, int]: + """보이는 시트마다 인쇄 쪽 수(그림 없이 — 그림 칸 크기는 행열이 정함).""" + from pypdf import PdfReader + + ids = [s["id"] for s in _visible(book)] + htmls = print_htmls([{"book": _only(book, {sid})} for sid in ids]) + pdfs = render_htmls_to_pdfs([{"html": h, "page_size": None} for h in htmls]) + return {sid: len(PdfReader(io.BytesIO(pdf)).pages) for sid, pdf in zip(ids, pdfs)} + + +def number_toc(book: dict[str, Any]) -> dict[str, int]: + """목차 시트가 있으면 쪽 수를 재서 쪽 번호를 채움(책을 고침) · 시트별 쪽 수.""" + pages = sheet_pages(book) + toc = next((s for s in book["시트"] if s["id"] == TOC and not s.get("숨김")), None) + if toc is not None: + fill_toc(toc, book, pages) + return pages + + +def _header(project: str) -> str: + return ( + f'
    {escape(project)}{REPORT_NAME}
    ' + ) + + +FOOTER = ( + f'
    ' + '- / -
    ' +) + + +def report_pdf(book: dict[str, Any], figures: dict[str, bytes]) -> bytes: + """보고서 PDF — 목차 쪽 번호 = 실제 쪽 · 표지 = 쪽 번호 · 머리말 없음.""" + clean = _only(book, set(), hide=True) + clean["시트"] = [ + {**s, "칸": {k: dict(v) for k, v in s["칸"].items()}, "행": dict(s.get("행") or {})} + if s["id"] == TOC + else s + for s in clean["시트"] + ] # 목차만 고칠 사본 + number_toc(clean) + ids = {s["id"] for s in _visible(clean)} + cover = next((s for s in clean["시트"] if s["id"] == COVER), None) + project = str(((cover or {}).get("칸", {}).get("C8") or {}).get("값") or "") + asks, jobs = [], [] + if COVER in ids: + asks.append({"book": _only(clean, {COVER}), "figures": _figures(clean, {COVER}, figures)}) + jobs.append({"page_size": None}) + if ids - {COVER}: + asks.append({"book": _only(clean, {COVER}, hide=True), "figures": figures}) + jobs.append( + {"page_size": None, "header_template": _header(project), "footer_template": FOOTER} + ) + htmls = print_htmls(asks) + pdfs = render_htmls_to_pdfs([{**job, "html": h} for job, h in zip(jobs, htmls)]) + return merge_pdfs(pdfs) if len(pdfs) > 1 else pdfs[0] + + +# ── 긴 표 쪽 나눔 ──────────────────────────────────────────────────────────── +def _col_px(chars: float) -> int: + """`spreadsheet_headers.colCharsToPx` 와 같은 꼴.""" + return round(min(chars, 255) * 7 + 5) + + +def _page_fit(sheet: dict[str, Any], book: dict[str, Any]) -> tuple[float, Any]: + """(쪽 하나 높이 px · 배율 반영) · 행 높이 px 함수 — 인쇄 HTML 과 같은 셈.""" + pr = sheet.get("인쇄") or {} + w, h = PAPER_MM[pr.get("용지") or "A4"] + if pr.get("방향") == "landscape": + w, h = h, w + m = {"위": 10, "아래": 10, "왼": 10, "오른": 10, **(pr.get("여백") or {})} + last_col = max( + (sum((ord(ch) - 64) * 26**i for i, ch in enumerate(reversed(A1.match(a).group(1)))) + for a in sheet["칸"]), + default=1, + ) # fmt: skip + cols = [_col_name(c) for c in range(1, last_col + 1)] + default_w = ( + (sheet.get("기본") or {}).get("열폭") or (book.get("기본") or {}).get("열폭") or 8.43 + ) + table = sum(_col_px((sheet.get("열") or {}).get(c, {}).get("폭") or default_w) for c in cols) + if (pr.get("맞춤") or {}).get("폭"): + zoom = min(1.0, (w - m["왼"] - m["오른"]) * PX_MM / max(table, 1)) + else: + zoom = (pr.get("배율") or 100) / 100 + default_pt = (book.get("기본") or {}).get("행높이") or 15 + + def row_px(r: int) -> float: + return ((sheet.get("행") or {}).get(str(r), {}).get("높이") or default_pt) * 4 / 3 + + return (h - m["위"] - m["아래"]) * PX_MM / zoom, row_px + + +def _col_name(n: int) -> str: + out = "" + while n: + n, k = divmod(n - 1, 26) + out = chr(65 + k) + out + return out + + +def balance_breaks(sheet: dict[str, Any], book: dict[str, Any], first: int, last: int) -> None: + """`first`~`last` 행 표를 쪽마다 고르게 나눔(쪽나눔 = 덩이 끝 행) — 한 쪽이면 그대로. + + ponytail: 한 줄 글 행만 맞음(줄바꿈으로 키 큰 행은 셈 밖) · 어긋남 대비 한 줄 덜 담음. + """ + if last < first: + return + page, row_px = _page_fit(sheet, book) + head = (sheet.get("인쇄") or {}).get("머리행") + head_px = sum(row_px(r) for r in range(head[0], head[1] + 1)) if head else 0.0 + pre_px = sum(row_px(r) for r in range(1, head[0] if head else first)) + rows = list(range(first, last + 1)) + cap_first = max(1, math.floor((page - pre_px - head_px) / row_px(first)) - 1) + cap_next = max(1, math.floor((page - head_px) / row_px(first)) - 1) + caps = [cap_first] + while sum(caps) < len(rows): # 쪽 수 + caps.append(cap_next) + if len(caps) == 1: + return + breaks, at, left = [], 0, len(rows) + for i, cap in enumerate(caps[:-1]): + take = min(cap, math.ceil(left / (len(caps) - i))) + at += take + left -= take + breaks.append(rows[at - 1]) + sheet["인쇄"]["쪽나눔"] = breaks diff --git a/E05_StabReport/E05_StabReport_Prob.py b/E05_StabReport/E05_StabReport_Prob.py new file mode 100644 index 000000000..4388dce08 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Prob.py @@ -0,0 +1,145 @@ +"""E05 보고서 s12 확률해석 — E03 확률 해석 저장본(대표 단면 · 비탈 · 조건마다 가장 최근) → 시트(PLAN 537-1). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s12. +저장본이 없으면 시트를 뺌(있을 때만) — `build_program` 이 책에서 지움. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +from E03_StabSection.E03_StabSection_Router_Prob import load_defaults, records +from E05_StabReport.E05_StabReport_Cells import _set, mark + +CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SIDE_NAMES = {"left": "좌", "right": "우"} +ROLE_NAMES = {"cut": "깎기", "fill": "쌓기"} +METHOD_NAMES = { + "ordinary": "Fellenius", + "bishop": "Bishop", + "janbu": "Janbu 보정", + "spencer": "Spencer", + "morgenstern_price": "M-P", +} +MODE_NAMES = {"fixed": "임계면 고정", "search": "매회 탐색"} +DIST_NAMES = {"normal": "정규", "lognormal": "대수정규"} +COLS = "ABCDEFGHIJK" + + +def read(project_root: Path) -> list[dict[str, Any]]: + return records(Path(project_root)) + + +def stamp(project_root: Path) -> str: + """해석 지문용 — 저장본 목록 내용(표본 F 뺌).""" + body = [ + {**r, "result": {k: v for k, v in r["result"].items() if k != "F"}} + for r in read(project_root) + ] + text = json.dumps(body, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha1(text.encode("utf-8")).hexdigest() + + +def _is_default(rec: dict[str, Any], defaults: dict[str, Any]) -> bool: + """요청 변수 · 표본 설정이 기본값 후보 그대로인지(「사용자 확정 전」 표기).""" + req = rec["request"] + props, own = defaults["properties"], defaults.get("materials") or {} + for v in req["variables"]: + base = { + **props[v["prop"]], + **((own.get(v.get("material") or "") or {}).get(v["prop"]) or {}), + } + spread = v.get("cov") if v.get("cov") is not None else v.get("std") + want = base.get("cov") if "cov" in base else base.get("std") + if ( + v["dist"] != base["dist"] + or spread != want + or v.get("trunc_sigma") != base["trunc_sigma"] + ): + return False + run = defaults["run"] + same = all(req.get(k) == run[k] for k in ("n", "sampling", "seed", "mode", "method")) + return same and req.get("rho_c_phi", 0.0) == defaults["rho_c_phi"] + + +def fill_prob(sheet: dict[str, Any], recs: list[dict[str, Any]], labels: dict) -> None: + """s12 — 1. 측점 · 비탈 · 조건마다 한 줄(6 행 서식) · 2. 변수마다 한 줄(10 행 서식 · 토네이도 순).""" + tpl = { + r: {c: copy.deepcopy(sheet["칸"].get(f"{c}{r}") or {}) for c in COLS} for r in (6, 8, 9, 10) + } + for a1 in [k for k in sheet["칸"] if int("".join(ch for ch in k if ch.isdigit())) >= 6]: + del sheet["칸"][a1] + sheet["병합"] = [ + m for m in sheet["병합"] if int("".join(ch for ch in m.split(":")[0] if ch.isdigit())) < 6 + ] + sheet["행"] = {k: v for k, v in sheet["행"].items() if int(k) < 6} + + def put_row(r: int, styles: dict[str, dict]) -> None: + for c, cell in styles.items(): + sheet["칸"][f"{c}{r}"] = copy.deepcopy(cell) + + def name(rec: dict[str, Any]) -> str: + ch = float(rec["chainage_m"]) + return rec.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}" + + def where(rec: dict[str, Any]) -> str: + return ( + f"{SIDE_NAMES[rec['side']]} {ROLE_NAMES.get(rec['role'], rec['role'])} · " + f"{CONDITION_NAMES.get(rec['condition'], rec['condition'])}" + ) + + defaults = load_defaults() + same = all(_is_default(rec, defaults) for rec in recs) + _set(sheet, "A3", mark("변동계수 · 분포 · 표본 = 문헌 기본값 후보(근거 E03 탭)", same)) + pct = lambda v: None if v is None else 100.0 * float(v) # noqa: E731 + r = 6 + for rec in recs: + res, st = rec["result"], rec["result"]["stats"] + put_row(r, tpl[6]) + _set(sheet, f"A{r}", name(rec)) + _set(sheet, f"B{r}", where(rec)) + _set( + sheet, + f"C{r}", + f"{METHOD_NAMES.get(res['method'], res['method'])} · {MODE_NAMES[res['mode']]}", + ) + _set(sheet, f"D{r}", int(st["n_valid"])) + _set(sheet, f"E{r}", rec.get("fs_deterministic")) + for c, k in zip("FG", ("mean", "std")): + _set(sheet, f"{c}{r}", st.get(k)) + _set(sheet, f"H{r}", pct(st.get("pf"))) + _set(sheet, f"I{r}", st.get("beta_normal")) + _set(sheet, f"J{r}", st.get("beta_lognormal")) + _set(sheet, f"K{r}", pct(st.get("p_below_required"))) + r += 1 + r += 1 + put_row(r, tpl[8]) + sheet["병합"].append(f"A{r}:K{r}") + sheet["행"][str(r)] = {"높이": 22} + sheet["행"][str(r + 1)] = {"높이": 30} + put_row(r + 1, tpl[9]) + r += 2 + for rec in recs: + res = rec["result"] + curves = {c["name"]: c for c in res["sensitivity"]["curves"]} + rank = {t["name"]: i + 1 for i, t in enumerate(res["sensitivity"]["tornado"])} + for v in sorted(res["variables"], key=lambda v: rank.get(v["name"], 99)): + c = curves.get(v["name"]) or {} + xs = c.get("x") or [None] + put_row(r, tpl[10]) + _set(sheet, f"A{r}", f"{name(rec)} · {where(rec)}") + _set(sheet, f"B{r}", v["name"]) + _set(sheet, f"C{r}", DIST_NAMES.get(v["dist"], v["dist"])) + _set(sheet, f"D{r}", float(v["mean"])) + _set(sheet, f"E{r}", float(v["std"])) + _set(sheet, f"F{r}", v["lo"] / v["std"] if v.get("lo") is not None else "-") + _set(sheet, f"G{r}", xs[0]) + _set(sheet, f"H{r}", c.get("F_lo")) + _set(sheet, f"I{r}", xs[-1]) + _set(sheet, f"J{r}", c.get("F_hi")) + _set(sheet, f"K{r}", rank.get(v["name"])) + r += 1 diff --git a/E05_StabReport/E05_StabReport_Reinforce.py b/E05_StabReport/E05_StabReport_Reinforce.py new file mode 100644 index 000000000..fa8b9865a --- /dev/null +++ b/E05_StabReport/E05_StabReport_Reinforce.py @@ -0,0 +1,210 @@ +"""E05 보고서 s8 보강검토 — E03 보강 검토 저장본(비탈 완화 · 소단 · 네일 · 앵커 가정 · 벽 외적 안정) → 시트 +(PLAN 536-3). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s8. +저장본이 없으면 시트를 뺌(있을 때만) — `build_program` 이 책에서 지움. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +from E03_StabSection.E03_StabSection_Router_WhatIf import records + +CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SIDE_NAMES = {"left": "좌", "right": "우"} +ROLE_NAMES = {"cut": "깎기", "fill": "쌓기"} +CASE_NAMES = {"normal": "평상시", "seismic": "지진 시"} +CHECKS = ("sliding", "overturning", "bearing") +SUPPORT_NAMES = {"nail": "네일", "anchor": "앵커"} +MODE_NAMES = { + "tensile": "인장", + "pullout": "인발", + "stripping": "머리 쪽 빠짐", + "not_crossing": "활동면 안 만남", + "prestress": "선인장(능동)", +} +SUPPORT_HEAD = ("측점", "비탈 · 조건", "번호 · 종류", "머리 x / z", "길이 m", "각도 °", "간격 m") +ST_NG, ST_OK = 10, 11 + + +def read(project_root: Path) -> list[dict[str, Any]]: + return records(Path(project_root)) + + +def stamp(project_root: Path) -> str: + """해석 지문용 — 저장본 목록 내용.""" + body = json.dumps(read(project_root), ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha1(body.encode("utf-8")).hexdigest() + + +def _set(sheet: dict[str, Any], a1: str, value: Any, style: int | None = None) -> None: + cell = dict(sheet["칸"].get(a1) or {}) + cell.pop("식", None) + if value is None: + cell.pop("값", None) + else: + cell["값"] = round(value, 6) if isinstance(value, float) else value + if style is not None: + cell["서식"] = style + sheet["칸"][a1] = cell + + +def _verdict(sheet: dict[str, Any], a1: str, ok: bool | None) -> None: + if ok is None: + _set(sheet, a1, "-") + else: + _set(sheet, a1, "O.K" if ok else "N.G", ST_OK if ok else ST_NG) + + +def _support_text(rec: dict[str, Any]) -> str: + sup = rec["assume"]["supports"] + first = sup[0] + text = ( + f"{SUPPORT_NAMES[first['type']]} {len(sup)}개 · L {first['length_m']:g} m · " + f"{first['angle_deg']:g}° · @{first['spacing_m']:g} m" + ) + search = rec.get("search") + if search: + goal = CONDITION_NAMES.get(search.get("condition"), "전 조건") + text += ( + f" · 최소 길이({goal} 목표)" + if search.get("found") + else f" · 최소 길이 못 찾음({goal} · {search.get('longest', 0):g} m)" + ) + if rec.get("supports_pending"): + text += " · 보강 엔진 연결 전(보강 없는 값)" + return text + + +def _assume_text(rec: dict[str, Any]) -> str: + if rec.get("type") == "support": + return _support_text(rec) + a = rec["assume"] + text = f"1:{a['ratio_before']:g} → 1:{a['ratio']:g}" + if a.get("bench_w") and a.get("bench_h"): + text += f" · 소단 폭 {a['bench_w']:g} m / 높이 {a['bench_h']:g} m 마다" + search = rec.get("search") + if search: + goal = CONDITION_NAMES.get(search.get("condition"), "전 조건") + text += ( + f" · 최소 완화({goal} 목표)" + if search.get("found") + else f" · 최소 완화 못 찾음({goal} · 끝값)" + ) + return text + + +def fill_reinforce(sheet: dict[str, Any], recs: list[dict[str, Any]], labels: dict) -> None: + """s8 — 1. 가정 줄(조건마다 한 줄 · 6 행 서식) · 2. 벽 줄(경우마다 한 줄 · 10 행 서식).""" + tpl = { + r: {c: copy.deepcopy(sheet["칸"].get(f"{c}{r}") or {}) for c in "ABCDEFGHI"} + for r in (8, 9, 10) + } + row6 = {c: copy.deepcopy(sheet["칸"].get(f"{c}6") or {}) for c in "ABCDEFGHI"} + for a1 in [k for k in sheet["칸"] if int("".join(ch for ch in k if ch.isdigit())) >= 6]: + del sheet["칸"][a1] + sheet["병합"] = [ + m for m in sheet["병합"] if int("".join(ch for ch in m.split(":")[0] if ch.isdigit())) < 6 + ] + sheet["행"] = {k: v for k, v in sheet["행"].items() if int(k) < 6} + + def put_row(r: int, styles: dict[str, dict]) -> None: + for c, cell in styles.items(): + sheet["칸"][f"{c}{r}"] = copy.deepcopy(cell) + + def name(rec: dict[str, Any]) -> str: + ch = float(rec["chainage_m"]) + return rec.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}" + + r = 6 + slopes = [x for x in recs if x["type"] in ("slope", "support")] + if any(x["type"] == "support" for x in slopes): + _set(sheet, "A4", "1. 비탈 완화 · 소단 · 네일 · 앵커 가정 — 전후 안전율 (Bishop)") + for rec in slopes: + a = rec["assume"] + for cond, after in rec["after"].items(): + before = rec["before"].get(cond) or {} + put_row(r, row6) + _set(sheet, f"A{r}", name(rec)) + _set(sheet, f"B{r}", f"{SIDE_NAMES[a['side']]} · {ROLE_NAMES[a['role']]}") + _set(sheet, f"C{r}", _assume_text(rec)) + _set(sheet, f"D{r}", CONDITION_NAMES.get(cond, cond)) + _set(sheet, f"E{r}", before.get("fs")) + _set(sheet, f"F{r}", after.get("fs")) + _set(sheet, f"G{r}", after.get("required")) + _verdict(sheet, f"H{r}", before.get("ok")) + _verdict(sheet, f"I{r}", after.get("ok")) + r += 1 + if not slopes: + put_row(r, row6) + _set(sheet, f"A{r}", "가정 없음") + r += 1 + r += 1 + put_row(r, tpl[8]) + sheet["병합"].append(f"A{r}:I{r}") + sheet["행"][str(r)] = sheet["행"][str(r + 1)] = {"높이": 22} + put_row(r + 1, tpl[9]) + r += 2 + walls = [x for x in recs if x["type"] == "wall"] + for rec in walls: + p = rec["params"] + for wall in rec["walls"]: + for case in wall["cases"]: + put_row(r, tpl[10]) + _set(sheet, f"A{r}", name(rec)) + _set( + sheet, + f"B{r}", + f"{wall.get('name') or '벽'} · {CASE_NAMES[case['case']]} · " + f"{'Coulomb' if p['method'] == 'coulomb' else 'Rankine'} · 수위 {p['water_ratio']:g}H", + ) + for c, need, check in zip("CEG", "DFH", CHECKS): + fs = case["fs"][check] + _set(sheet, f"{c}{r}", fs if fs == fs and abs(fs) < 1e6 else None) + _set(sheet, f"{need}{r}", case["required"][check]) + _verdict(sheet, f"I{r}", all(case["ok"].values())) + r += 1 + if not walls: + put_row(r, tpl[10]) + _set(sheet, f"A{r}", "벽 검토 없음") + r += 1 + supports = [x for x in recs if x["type"] == "support"] + if not supports: + return + r += 1 + put_row(r, tpl[8]) + _set(sheet, f"A{r}", "3. 네일 · 앵커 — 네일마다 동원력 · 지배 (단위 폭 = 힘 / 간격)") + sheet["병합"].append(f"A{r}:I{r}") + sheet["행"][str(r)] = sheet["행"][str(r + 1)] = {"높이": 22} + put_row(r + 1, tpl[9]) + for c, text in zip("ABCDEFGHI", (*SUPPORT_HEAD, "동원력 kN/m", "지배")): + _set(sheet, f"{c}{r + 1}", text) + r += 2 + for rec in supports: + a = rec["assume"] + forces = rec.get("support_forces") or {} + for cond in rec["after"]: + by = {f["index"]: f for f in forces.get(cond) or []} + for i, sup in enumerate(a["supports"]): + f = by.get(i) + put_row(r, tpl[10]) + _set(sheet, f"A{r}", name(rec)) + _set( + sheet, + f"B{r}", + f"{SIDE_NAMES[a['side']]} · {ROLE_NAMES[a['role']]} · " + f"{CONDITION_NAMES.get(cond, cond)}", + ) + _set(sheet, f"C{r}", f"{i + 1} · {SUPPORT_NAMES[sup['type']]}") + _set(sheet, f"D{r}", f"{sup['x']:.2f} / {sup['z']:.2f}") + _set(sheet, f"E{r}", float(sup["length_m"])) + _set(sheet, f"F{r}", float(sup["angle_deg"])) + _set(sheet, f"G{r}", float(sup["spacing_m"])) + _set(sheet, f"H{r}", float(f["mobilized_kN_per_m"]) if f else None) + _set(sheet, f"I{r}", MODE_NAMES.get(f["mode"], f["mode"]) if f else "-") + r += 1 diff --git a/E05_StabReport/E05_StabReport_Rock.py b/E05_StabReport/E05_StabReport_Rock.py new file mode 100644 index 000000000..7e4e85c07 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Rock.py @@ -0,0 +1,228 @@ +"""E05 보고서 s11 암반검토 — E06 암반 해석 저장본(절리 · Markland · Hoek-Bray) → 시트 (PLAN 536-1). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s11. +저장본이 없으면 시트를 뺌(있을 때만) — `build_program` 이 책에서 지움. +""" + +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Any + +from fastapi import HTTPException + +from E05_StabReport.E05_StabReport_Cells import _set, _verdict, mark +from E06_StabRock import E06_StabRock_Model as rock_model +from E06_StabRock.E06_StabRock_Router import _result + +CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SIDE_NAMES = {"left": "좌", "right": "우"} +PROJECTION_NAMES = {"equal_angle": "등각(Wulff)", "equal_area": "등면적(Schmidt)"} +VERDICT_NAMES = {"NG": "N.G", "check": "검토", "OK": "O.K", "none": "위험 모드 없음"} +SLIDE_NAMES = {"1": "한 면({a}) 미끄러짐", "2": "한 면({b}) 미끄러짐", "12": "두 면 미끄러짐"} +COLS = "ABCDEFGH" +FIRST = 11 # 이 행부터 절리 · 측점 표를 다시 깔음 + + +def read(project_root: Path) -> dict[str, Any] | None: + """보고서에 쓰는 E06 결과(지금 입력 지문 것 · 없으면 가장 최근) + 낡음 표시.""" + try: + out = _result(Path(project_root), None, None) + except HTTPException: + return None + return {**out["result"], "stale": out["stale"]} if out["result"] else None + + +def stamp(project_root: Path) -> list | None: + """해석 지문용 — 결과 지문 · 낡음.""" + rock = read(project_root) + return [rock["fingerprint"], rock["stale"]] if rock else None + + +def _row_styles(sheet: dict[str, Any], r: int) -> dict[str, dict]: + return {c: copy.deepcopy(sheet["칸"].get(f"{c}{r}") or {}) for c in COLS} + + +def _summary(rock: dict[str, Any]) -> str: + s = rock["summary"] + v = s["verdicts"] + text = ( + f"깎기 비탈 {s['slopes']} · " + + " · ".join(f"{VERDICT_NAMES[k]} {v[k]}" for k in VERDICT_NAMES if v.get(k)) + + f" — 위험 모드 평면 {s['modes']['planar']} · 쐐기 {s['modes']['wedge']} · " + f"전도 {s['modes']['toppling']}" + ) + if s.get("small_blocks"): + text += f" · 소규모 블록 {s['small_blocks']} (판정에서 뺌 · 낙석 관리)" + if rock["stale"]: + text += " · 입력이 해석 뒤 바뀜 — E06 [해석] 뒤 [보고서 갱신]" + return text + + +def fill_rock(sheet: dict[str, Any], rock: dict[str, Any], labels: dict) -> None: + """s11 — 1. 조건 · 요약(5~9 행) · 2. 절리군(한 줄씩 · 13 행 서식) · 3. 측점(17 행 서식 · 머리 줄 쪽마다).""" + s = rock["settings"] + d = rock_model.DEFAULT_SETTINGS + lateral = (s["lateral_planar_deg"], s["lateral_toppling_deg"]) + _set( + sheet, + "B5", + mark( + f"{PROJECTION_NAMES.get(s['projection'], s['projection'])} 하반구 · " + f"측방 한계 평면 {lateral[0]:g}° · 전도 {lateral[1]:g}°", + lateral == (d["lateral_planar_deg"], d["lateral_toppling_deg"]), + ), + ) + _set( + sheet, + "B6", + f"{s['gamma_r']:g} kN/m³ (사용자 입력)" + if s.get("gamma_r") + else "E02 측점 암 물성 γt (문헌 추정치 · 사용자 확정 전)", + ) + _set( + sheet, + "B7", + mark( + f"인장균열 깊이 · 활동면 높이의 {s['water_fill_wet']:g} 까지 지하수 " + "(KDS 11 70 05 표 4.3-3 암반 = ½ 심도)", + s["water_fill_wet"] == d["water_fill_wet"], + ), + ) + crack = s["crack"] + crack_text = { + "none": "인장균열 없음", + "critical": "인장균열 = 임계(최소 안전율 자리)", + "depth": f"인장균열 깊이 {crack.get('z') or 0:g} m", + }[crack["mode"]] + bolt = s["bolt"] + bolt_text = ( + f"볼트 {bolt['force']:g} (평면 kN/m · 쐐기 kN) · 수평 아래 {bolt['plunge']:g}°" + if bolt["force"] + else "볼트 없음" + ) + min_block = s.get("min_block_m3", d["min_block_m3"]) + _set( + sheet, + "B8", + f"{crack_text} · {bolt_text} · " + + mark( + f"최소 블록 {min_block:g} m³ (이보다 작으면 판정에서 뺌)", + min_block == d["min_block_m3"], + ), + ) + _set(sheet, "B9", _summary(rock)) + + tpl = {r: _row_styles(sheet, r) for r in (11, 12, 13, 15, 16, 17)} + merged = [m for m in sheet["병합"] if m.startswith(("A11:", "A15:"))] + for a1 in [k for k in sheet["칸"] if int("".join(ch for ch in k if ch.isdigit())) >= FIRST]: + del sheet["칸"][a1] + sheet["병합"] = [m for m in sheet["병합"] if m not in merged] + heights = {k: v for k, v in sheet["행"].items() if int(k) >= FIRST} + sheet["행"] = {k: v for k, v in sheet["행"].items() if int(k) < FIRST} + + def put_row(r: int, src: int) -> None: + for c, cell in tpl[src].items(): + if cell: + sheet["칸"][f"{c}{r}"] = copy.deepcopy(cell) + if str(src) in heights: + sheet["행"][str(r)] = dict(heights[str(src)]) + + def head(r: int, src: int) -> int: + put_row(r, src) + sheet["병합"].append(f"A{r}:H{r}") + put_row(r + 1, src + 1) + return r + 2 + + # 2. 절리군 + r = head(FIRST, 11) + sets = _sets(rock) + for j in sets: + put_row(r, 13) + _set(sheet, f"A{r}", j.get("name") or j["id"]) + for col, key in zip("BCDEF", ("dip_direction", "dip", "phi", "c", "persistence_m")): + value = j.get(key) + _set(sheet, f"{col}{r}", float(value) if value is not None else "-") + _set(sheet, f"G{r}", _station_text(labels, j.get("from_m"))) + _set(sheet, f"H{r}", _station_text(labels, j.get("to_m"))) + r += 1 + if not sets: + put_row(r, 13) + _set(sheet, f"A{r}", "절리군 없음") + r += 1 + + # 3. 측점별 판정 + r = head(r + 1, 15) + sheet["인쇄"]["머리행"] = [r - 1, r - 1] # 측점 표 머리 줄 = 쪽마다 + for entry in rock["stations"]: + r = _station_rows(sheet, put_row, r, entry, labels) + + +def _sets(rock: dict[str, Any]) -> list[dict[str, Any]]: + """해석에 쓴 절리군 — 옛 저장본(sets 없음)은 측점 결과에 실린 것(지속성 · 구간 없음).""" + if "sets" in rock: + return rock["sets"] + seen: dict[str, dict] = {} + for entry in rock["stations"]: + for j in entry["joints"]: + seen.setdefault(j["id"], j) + return list(seen.values()) + + +def _station_text(labels: dict, chainage: Any) -> Any: + if chainage is None: + return "-" + ch = float(chainage) + return labels.get(round(ch, 3)) or f"{ch:.2f}" + + +def _station_rows(sheet, put_row, r: int, entry: dict[str, Any], labels: dict) -> int: + slope = entry["slope"] + ch = float(entry["chainage_m"]) + name = entry.get("label") or labels.get(round(ch, 3)) or f"{ch:.2f}" + side = ( + f"{SIDE_NAMES.get(slope['side'], slope['side'])} · {slope['dip']:g}° / " + f"{slope['dip_direction']:g}° · 높이 {slope['height_m']:.1f} m" + ) + jname = {j["id"]: j.get("name") or j["id"] for j in entry["joints"]} + rows: list[tuple[str, str, Any, Any, bool | None, str]] = [] + for case in entry["cases"]: + ids = [jname.get(i, i) for i in case["ids"]] + mode = f"평면 {ids[0]}" if case["mode"] == "planar" else f"쐐기 {ids[0]} × {ids[1]}" + notes = [] + if not case["formed"]: + notes.append("블록 안 생김") + if case.get("sliding") == "lift": + notes.append("들림") + elif case.get("sliding") in SLIDE_NAMES: + notes.append(SLIDE_NAMES[case["sliding"]].format(a=ids[0], b=ids[-1])) + if case.get("small"): + notes.append("소규모 · 낙석 관리") + if case.get("persistence_short"): + notes.append("지속성 < 활동면") + cond = CONDITION_NAMES.get(case["condition"], case["condition"]) + rows.append( + (mode, cond, case["F"], case.get("required"), case.get("ok"), " · ".join(notes)) + ) + for jid in entry["kinematic"]["toppling"]: + rows.append( + (f"전도 {jname.get(jid, jid)}", "-", None, None, None, "기하 판정만 — 현장 확인") + ) + if not rows: + rows.append(("없음", "-", None, None, None, "위험 모드 없음(Markland)")) + for mode, cond, fs, need, ok, note in rows: + put_row(r, 17) + _set(sheet, f"A{r}", name) + _set(sheet, f"B{r}", side) + _set(sheet, f"C{r}", mode) + _set(sheet, f"D{r}", cond) + _set(sheet, f"E{r}", float(fs) if fs is not None else "-") + _set(sheet, f"F{r}", float(need) if need is not None else "-") + if ok is None: + _set(sheet, f"G{r}", "검토" if mode.startswith("전도") else "-") + else: + _verdict(sheet, f"G{r}", ok) + _set(sheet, f"H{r}", note or None) + r += 1 + return r diff --git a/E05_StabReport/E05_StabReport_Router.py b/E05_StabReport/E05_StabReport_Router.py new file mode 100644 index 000000000..28961e1ec --- /dev/null +++ b/E05_StabReport/E05_StabReport_Router.py @@ -0,0 +1,240 @@ +"""E05 안정성평가 보고서 API (요구 347-5 · 351 · 요구서 4-3 · PLAN 534-3). + +- `GET /api/projects/{id}/stab/report` — 작업본(없고 2D 결과가 있으면 처음 만듦 + 초기값) · 판 · + 해석 지문이 바뀌었는지(`stale`) · 고칠 수 있는지 +- `PUT …/stab/report` — [저장] `{rev, book}` · 판이 다르면 409 · 서버 재계산(`with_server_values`) +- `POST …/stab/report/refresh` — [보고서 갱신] `{rev}` · 프로그램 몫 시트 · 그림만 다시 · + 덮인 시트에 사용자 고침이 있었으면 `overwritten` + 옛 판 `_지난/` +- `POST …/stab/report/reset` — [초기화] 초기값을 작업본에 덮어씀 +- `POST …/stab/report/xlsx` · `…/pdf` `{book}` — 화면 지금 책을 서버 재계산 → xlsx · PDF(저장 안 함) +- `GET …/stab/report/xlsx` · `…/pdf` — 작업본으로(보기만 허용된 회사도) +- `GET …/stab/report/fig?key=` — 그림 PNG + +계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company` +(지정받은 회사 = GET 만 · 그 밖 403). +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any +from urllib.parse import quote +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse, Response +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from common_util.common_util_print_pdf import PrintError +from common_util.common_util_spreadsheet import RecalcError, with_server_values +from common_util.common_util_storage import resolve_stored_project_path +from common_util.common_util_workbook_xlsx import workbook_to_xlsx +from config.config_db import get_db_pool +from E05_StabReport import E05_StabReport_Build as build +from E05_StabReport import E05_StabReport_Print as printer +from E05_StabReport import E05_StabReport_Store as store + +router = APIRouter(prefix="/api/projects", tags=["E05 Stab Report"]) +XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +TITLE = "안정성평가_보고서" +_LOCKS: dict[str, asyncio.Lock] = {} + + +class SaveRequest(BaseModel): + rev: str | None = None + book: dict[str, Any] + + +class RevRequest(BaseModel): + rev: str | None = None + + +class BookRequest(BaseModel): + book: dict[str, Any] + + +def _lock(project_id: UUID) -> asyncio.Lock: + return _LOCKS.setdefault(str(project_id), asyncio.Lock()) + + +async def _project(project_id: UUID) -> tuple[Path, int | None]: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT storage_path, company_id FROM projects" + " WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL", + (str(project_id),), + ) + row = await cursor.fetchone() + if not row or not row["storage_path"]: + raise HTTPException(status_code=404, detail="안정성평가 프로젝트가 아닙니다.") + return Path(resolve_stored_project_path(row["storage_path"])), row["company_id"] + + +def _editable(session: dict[str, Any], company_id: int | None) -> bool: + return session["role"] == "SYSTEM_ADMIN" or session.get("company_id") == company_id + + +def _check_book(book: dict[str, Any]) -> None: + sheets = book.get("시트") + if book.get("종류") != "통합문서" or not isinstance(sheets, list) or not sheets: + raise HTTPException(status_code=400, detail="통합문서 꼴이 아닙니다.") + + +def _state(root: Path) -> dict[str, Any]: + book, meta = store.load(root) + return { + "book": book, + "rev": store.revision(root), + "meta": meta, + "stale": bool(meta) and meta.get("fingerprint") != build.analysis_fingerprint(root), + "has_initial": (build.initial_dir(root) / build.REPORT).is_file(), + } + + +def _ensure(root: Path) -> dict[str, Any]: + """작업본이 없으면 처음 만듦(2D 결과가 없으면 빈 상태 · 까닭).""" + if store.load(root)[0] is None: + try: + store.create(root) + except build.MissingInput as error: + return {"book": None, "reason": str(error)} + return _state(root) + + +def _recalc_errors(error: Exception) -> HTTPException: + if isinstance(error, PrintError): + return HTTPException(status_code=503, detail=str(error)) + return HTTPException(status_code=422, detail=str(error)) + + +@router.get("/{project_id}/stab/report", response_model=None) +async def get_report( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root, company_id = await _project(project_id) + async with _lock(project_id): + try: + state = await asyncio.to_thread(_ensure, root) + except (RecalcError, PrintError) as error: + raise _recalc_errors(error) from error + return {"status": "success", "editable": _editable(session, company_id), **state} + + +@router.put("/{project_id}/stab/report", response_model=None) +async def put_report(project_id: UUID, payload: SaveRequest) -> dict[str, Any]: + root, _ = await _project(project_id) + _check_book(payload.book) + async with _lock(project_id): + if store.load(root)[0] is None: + raise HTTPException(status_code=409, detail="보고서가 아직 없습니다.") + if payload.rev != store.revision(root): + raise HTTPException( + status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요." + ) + try: + await asyncio.to_thread(store.save, root, payload.book) + except RecalcError as error: + raise _recalc_errors(error) from error + return {"status": "success", **await asyncio.to_thread(_state, root)} + + +@router.post("/{project_id}/stab/report/refresh", response_model=None) +async def refresh_report(project_id: UUID, payload: RevRequest) -> dict[str, Any]: + root, _ = await _project(project_id) + async with _lock(project_id): + current = store.revision(root) + if current is not None and payload.rev != current: + raise HTTPException( + status_code=409, detail="다른 곳에서 보고서가 바뀌었습니다 — 다시 여세요." + ) + try: + result = await asyncio.to_thread(store.refresh, root) + except build.MissingInput as error: + raise HTTPException(status_code=409, detail=str(error)) from error + except (RecalcError, PrintError) as error: + raise _recalc_errors(error) from error + return { + "status": "success", + "overwritten": result["overwritten"], + **await asyncio.to_thread(_state, root), + } + + +@router.post("/{project_id}/stab/report/reset", response_model=None) +async def reset_report(project_id: UUID) -> dict[str, Any]: + root, _ = await _project(project_id) + async with _lock(project_id): + if not await asyncio.to_thread(store.reset, root): + raise HTTPException(status_code=404, detail="초기값이 없습니다.") + return {"status": "success", **await asyncio.to_thread(_state, root)} + + +def _file(data: bytes, media: str, ext: str, inline: bool = False) -> Response: + kind = "inline" if inline else "attachment" + name = quote(f"{TITLE}.{ext}") + return Response( + content=data, + media_type=media, + headers={"Content-Disposition": f"{kind}; filename*=UTF-8''{name}"}, + ) + + +def _export(root: Path, book: dict[str, Any] | None, kind: str) -> bytes: + if book is None: + book = store.load(root)[0] + if book is None: + raise HTTPException(status_code=404, detail="보고서가 아직 없습니다.") + figures = store.figures_of(root, book) + if kind == "xlsx": + return workbook_to_xlsx(with_server_values(book), figures) + return printer.report_pdf(book, figures) # 인쇄 HTML 이 다시 풂(브라우저 값 안 씀) + + +async def _export_response(project_id: UUID, book: dict[str, Any] | None, kind: str) -> Response: + root, _ = await _project(project_id) + if book is not None: + _check_book(book) + try: + data = await asyncio.to_thread(_export, root, book, kind) + except (RecalcError, PrintError) as error: + raise _recalc_errors(error) from error + if kind == "xlsx": + return _file(data, XLSX, "xlsx") + return _file(data, "application/pdf", "pdf", inline=True) + + +@router.post("/{project_id}/stab/report/xlsx") +async def post_xlsx(project_id: UUID, payload: BookRequest) -> Response: + return await _export_response(project_id, payload.book, "xlsx") + + +@router.get("/{project_id}/stab/report/xlsx") +async def get_xlsx(project_id: UUID) -> Response: + return await _export_response(project_id, None, "xlsx") + + +@router.post("/{project_id}/stab/report/pdf") +async def post_pdf(project_id: UUID, payload: BookRequest) -> Response: + return await _export_response(project_id, payload.book, "pdf") + + +@router.get("/{project_id}/stab/report/pdf") +async def get_pdf(project_id: UUID) -> Response: + return await _export_response(project_id, None, "pdf") + + +@router.get("/{project_id}/stab/report/fig") +async def get_figure(project_id: UUID, key: str) -> FileResponse: + root, _ = await _project(project_id) + if not key.startswith("fig/") or ".." in key: + raise HTTPException(status_code=400, detail="그림 열쇠가 틀렸습니다.") + path = build.fig_path(build.stage_dir(root), key) + if not path.is_file(): + raise HTTPException(status_code=404, detail="그림이 없습니다.") + return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"}) diff --git a/E05_StabReport/E05_StabReport_Section.py b/E05_StabReport/E05_StabReport_Section.py new file mode 100644 index 000000000..37ef40bf6 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Section.py @@ -0,0 +1,318 @@ +"""E05 보고서 s5 대표단면 · s10 부록 절편 — 단면 하나 = 한 쪽(PLAN 534-3 · 534-5). + +칸 자리 = `resources/master_template/report/stab_report_slots.md` s5 · s10. +그림 = `E05_StabReport_Figure.section_svg`(값 셈 없음 · 해석 저장본 옮겨 그림). +""" + +from __future__ import annotations + +import copy +import math +from typing import Any + +from E03_StabSection import E03_StabSection_View as view2d +from E03_StabSection.E03_StabSection_Engine_Model import final_surface +from E05_StabReport import E05_StabReport_Figure as figure +from E05_StabReport.E05_StabReport_Cells import ( + A1, + CASES, + GROUND_NAMES, + METHOD_NAMES, + REASONS, + SIDE_NAMES, + _criterion, + _label_at, + _labels, + _seismic, + _set, + _shift, + _verdict, + fig_key, +) + +BLOCK = 28 # 대표단면 한 쪽 = 28 행 +LOWEST_N = view2d.REPRESENTATIVE_LOWEST +CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" +CONDITION_NAMES = {"dry": "건기", "wet": "우기", "seismic": "지진"} +SLICE_COLS = "ABCDEFGHIJKLM" +SLICE_TPL = 5 # 부록 절편 덩이 꼴 = 양식 1~5 행(제목 · 단위 · 머리 · 줄 · 합계) + + +def _case_slip(result: dict[str, Any] | None, condition: str, side: str, method: str): + """조건 하나 — 그 비탈 역할(깎기 · 쌓기) 중 그 방법 최소 면 · 높이.""" + best = None + for case in (result or {}).get("cases") or []: + if case.get("condition") != condition: + continue + if side in ("cut", "fill") and case.get("role") not in (side, None): + continue + methods = (case.get("result") or {}).get("methods") or {} + found = (methods.get(method) or {}).get("min") or next( + (v["min"] for v in methods.values() if v.get("min")), None + ) + if found and found.get("F") is not None and (best is None or found["F"] < best[0]["F"]): + best = (found, case) + return best + + +def _block_copy( + sheet: dict[str, Any], template: dict[str, Any], dr: int, rows: int, tag: str +) -> None: + """양식 1~rows 행 덩이를 dr 행 아래로(칸 · 병합 · 행 높이 · 조건부서식).""" + for a1, cell in template["칸"].items(): + if int(A1.match(a1).group(2)) <= rows: + sheet["칸"][_shift(a1, dr)] = copy.deepcopy(cell) + for m in template.get("병합", []): + if int(A1.match(m.split(":")[0]).group(2)) <= rows: + sheet["병합"].append(":".join(_shift(p, dr) for p in m.split(":"))) + for r, info in (template.get("행") or {}).items(): + if int(r) <= rows: + sheet["행"][str(int(r) + dr)] = dict(info) + for rule in template.get("조건부서식", []): + moved = copy.deepcopy(rule) + moved["id"] = f"{rule['id']}_{tag}" + moved["범위"] = [{**g, "r0": g["r0"] + dr, "r1": g["r1"] + dr} for g in rule["범위"]] + sheet["조건부서식"].append(moved) + + +def _clear(sheet: dict[str, Any]) -> dict[str, Any]: + """시트를 비우고 양식 사본을 돌려줌.""" + template = copy.deepcopy(sheet) + sheet["칸"] = {} + sheet["병합"], sheet["행"], sheet["조건부서식"] = [], {}, [] + if "그림" in sheet: + sheet["그림"] = [] + return template + + +def _mark(k: int) -> str: + return CIRCLED[k] if k < len(CIRCLED) else str(k + 1) + + +def _picks(rep: dict[str, Any], ground: dict[str, Any]) -> dict[str, Any]: + """대표 단면 하나 — 조건별 (면, 경우) · 쓰는 조건 · 지배 조건(Fs / 기준 가장 낮음).""" + st = rep["summary"] + side, method = st.get("side"), st.get("method") or "bishop" + used = [c for c in CASES if c != "seismic" or _seismic(ground)] + picks = {c: _case_slip(rep.get("result"), c, side, method) for c in used} + fs = st.get("fs_by_condition") or {} + ratios = [ + (float(fs[c]) / _criterion(ground, c, side), c) + for c in used + if fs.get(c) is not None and picks.get(c) + ] + governing = min(ratios)[1] if ratios else None + return {"side": side, "method": method, "used": used, "picks": picks, "governing": governing} + + +def fill_sections(sheet: dict[str, Any], inputs: dict[str, Any]) -> list[tuple[str, str]]: + """s5 대표단면 — 단면 하나 = 28 행 덩이 = 한 쪽 · 그림 일감 [(열쇠, SVG)].""" + reps = inputs["representatives"] + ground = inputs["ground"] + labels = _labels(inputs["stations"]) + template = _clear(sheet) + if not reps: + _block_copy(sheet, template, 0, BLOCK, "0") + _set(sheet, "A1", "대표 단면 — 해당 없음") + sheet["인쇄"]["쪽나눔"] = [] + return [] + jobs: list[tuple[str, str]] = [] + for k, rep in enumerate(reps): + dr = BLOCK * k + _block_copy(sheet, template, dr, BLOCK, str(k)) + st = rep["summary"] + got = _picks(rep, ground) + side, method, picks = got["side"], got["method"], got["picks"] + label = _label_at(labels, float(st["chainage_m"])) + dry = picks.get("dry") + height = float(dry[1].get("height_m") or 0) if dry else 0.0 + _set( + sheet, + f"A{1 + dr}", + f"대표 단면 {_mark(k)} — {label} · {SIDE_NAMES.get(side, side or '-')} · " + f"H {height:.1f} m", + ) + reason = REASONS.get(rep.get("reason"), rep.get("reason") or "") + if rep.get("reason") == "lowest": + reason = f"{reason} {LOWEST_N}" + _set(sheet, f"B{2 + dr}", reason) + key = fig_key(rep["station_key"]) + sheet["그림"].append( + {"열쇠": key, "닻": f"A{3 + dr}:H{22 + dr}", "이름": f"대표 단면 {label}"} + ) + fs = st.get("fs_by_condition") or {} + slips, box = [], [] + for r, case in zip((24, 25, 26), CASES): + row = r + dr + if case not in got["used"]: + _set(sheet, f"B{row}", "검토 안 함") + continue + value = fs.get(case) + crit = _criterion(ground, case, side) + ok = None if value is None else float(value) >= crit - 1e-9 + pick = picks.get(case) + _set(sheet, f"B{row}", METHOD_NAMES.get(method, method)) + _set(sheet, f"C{row}", float(value) if value is not None else None) + _set(sheet, f"D{row}", crit) + _verdict(sheet, f"E{row}", ok) + slip = pick[0] if pick else None + center = (slip or {}).get("center") + circle = bool(slip and slip.get("radius") and center) + _set(sheet, f"F{row}", float(slip["radius"]) if circle else "-" if slip else None) + _set(sheet, f"G{row}", float(center[0]) if circle else "-" if slip else None) + _set(sheet, f"H{row}", float(center[1]) if circle else "-" if slip else None) + if slip and slip.get("points"): + slips.append({"condition": case, "F": slip.get("F"), "points": slip["points"]}) + box.append({"condition": case, "F": value, "required": crit, "ok": ok}) + design = (rep.get("cross") or {}).get("design") or {} + kind = (dry[0].get("kind") if dry else None) or "" + layers = rep.get("layers") or {} + note = [ + {"circle": "활동면 원호", "polyline": "활동면 비원호"}.get(kind, ""), + f"지반 {GROUND_NAMES.get(design.get('ground_type'), design.get('ground_type') or '-')}", + f"토층 두께 {layers['soil_thickness_m']:g} m" if layers.get("soil_thickness_m") else "", + f"부록 {_mark(k)} 절편 계산표" if got["governing"] else "", + ] + _set(sheet, f"B{27 + dr}", " · ".join(n for n in note if n)) + jobs.append((key, _section_figure(rep, slips, box, ground))) + sheet["인쇄"]["쪽나눔"] = [BLOCK * (k + 1) for k in range(len(reps))] + return jobs + + +def _strata(rep: dict[str, Any], ground: dict[str, Any], fill: bool) -> list[dict[str, str]]: + """지층 범례 — 성토(공사 뒤 지표가 원지반 위인 곳이 있으면) · 위 지층 · 아래 지층.""" + names = {m["id"]: m for m in ground.get("materials") or []} + layers = rep.get("layers") or {} + out = [] + picks = [ + ("fill", (ground.get("label_map") or {}).get("fill") or "fill", fill), + ("soil", layers.get("upper"), True), + ("rock", layers.get("lower"), bool(layers.get("soil_thickness_m"))), + ] + for kind, mid, show in picks: + m = names.get(mid) + if show and m: + text = f"{m['name']} γt {m['gamma_t']:g} · c {m['c']:g} · φ {m['phi']:g}" + out.append({"kind": kind, "text": text}) + return out + + +def _section_figure( + rep: dict[str, Any], slips: list[dict[str, Any]], box: list[dict[str, Any]], soil: dict +) -> str: + cross = rep.get("cross") or {} + design = cross.get("design") or {} + ground = sorted( # 해석에 쓴 넓힌 지반선(임계면이 횡단 표본 밖까지 감) · 없으면 횡단 표본 + (float(x), float(z)) for x, z in rep.get("ground") or [] + ) or sorted( + (float(s["offset_m"]), float(s["z"])) + for s in cross.get("samples") or [] + if s.get("valid", True) and s.get("z") is not None + ) + line = sorted( + (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or [] + ) + surface = [tuple(p) for p in final_surface(ground, line)] if ground and line else ground + thickness = (rep.get("layers") or {}).get("soil_thickness_m") + rock = [(x, z - float(thickness)) for x, z in ground] if thickness else None + edges = design.get("road_edges") or {} + road = None + if edges.get("left") and edges.get("right"): + road = (float(edges["right"]["offset_m"]), float(edges["left"]["offset_m"])) + heights = [] + if road and line: + for case in (rep.get("result") or {}).get("cases") or []: + if case.get("condition") != "dry" or not case.get("height_m"): + continue + left = case.get("side") == "left" + edge = road[1] if left else road[0] + day = line[-1][0] if left else line[0][0] + z_edge, z1 = figure._interp(surface, edge), figure._interp(surface, day) + if z_edge is not None and z1 is not None: # 높이 = 엔진 값(비탈 끝에서 길 쪽으로) + h = float(case["height_m"]) + z0 = z1 + h if z_edge > z1 else z1 - h + heights.append({"x": day, "z0": z0, "z1": z1, "label": f"H {h:.2f} m"}) + filled = any( + (z_g := figure._interp(ground, x)) is not None and z - z_g > 0.05 for x, z in surface + ) + return figure.section_svg( + title="", + ground=ground, + surface=surface, + rock=rock, + slips=slips, + road=road, + heights=heights, + wet=any(s["condition"] == "wet" for s in slips), + strata=_strata(rep, soil, filled), + fs_box=box, + ) + + +# ── s10 부록 절편 ──────────────────────────────────────────────────────────── +def fill_slices(sheet: dict[str, Any], inputs: dict[str, Any]) -> None: + """s10 부록 절편 — 대표 단면마다 지배 조건(Fs / 기준 가장 낮음) 임계면 절편표 한 쪽.""" + ground = inputs["ground"] + labels = _labels(inputs["stations"]) + template = _clear(sheet) + data_tpl = {c: dict(template["칸"].get(f"{c}4") or {}) for c in SLICE_COLS} + sum_tpl = {c: dict(template["칸"].get(f"{c}5") or {}) for c in SLICE_COLS} + data_h = (template.get("행") or {}).get("4") + sum_h = (template.get("행") or {}).get("5") + dr, breaks = 0, [] + for k, rep in enumerate(inputs["representatives"]): + got = _picks(rep, ground) + cond = got["governing"] + slip = got["picks"][cond][0] if cond else None + rows = (slip or {}).get("slices") or [] + if not rows: + continue + _block_copy(sheet, template, dr, 3, str(k)) + label = _label_at(labels, float(rep["summary"]["chainage_m"])) + F = float(slip["F"]) + _set( + sheet, + f"A{1 + dr}", + f"부록 {_mark(k)} 절편 계산표 — {label} · {SIDE_NAMES.get(got['side'], '-')} · " + f"{CONDITION_NAMES[cond]} · {METHOD_NAMES.get(got['method'], got['method'])} · " + f"Fs {F:.3f}", + ) + kind = {"circle": "원호", "polyline": "비원호"}.get(slip.get("kind"), "") + _set( + sheet, + f"A{2 + dr}", + f"대표 단면 {_mark(k)} 의 {CONDITION_NAMES[cond]} 임계 활동면({kind}) · " + f"{template['칸']['A2']['값']}", + ) + sums = [0.0, 0.0, 0.0, 0.0] + for i, s in enumerate(rows): + r = 4 + dr + i + for c, cell in data_tpl.items(): + sheet["칸"][f"{c}{r}"] = dict(cell) + if data_h: + sheet["행"][str(r)] = dict(data_h) + drive = (s["weight"] + s["surcharge"]) * math.sin(math.radians(s["alpha_deg"])) + resist = s["shear_mobilized"] * F + values = ( + i + 1, s["width"], s["height"], s["alpha_deg"], s["weight"], s["surcharge"], + s["pore_pressure"], s["base_length"], s["c"], s["phi_deg"], s["normal_eff"], + drive, resist, + ) # fmt: skip + for c, v in zip(SLICE_COLS, values): + _set(sheet, f"{c}{r}", float(v) if c != "A" else v) + for j, v in enumerate((s["weight"], s["surcharge"], drive, resist)): + sums[j] += v + r = 4 + dr + len(rows) + for c, cell in sum_tpl.items(): + sheet["칸"][f"{c}{r}"] = dict(cell) + if sum_h: + sheet["행"][str(r)] = dict(sum_h) + _set(sheet, f"A{r}", "합계") + for c, v in zip("EFLM", sums): + _set(sheet, f"{c}{r}", v) + dr = r + breaks.append(r) + if not breaks: + _block_copy(sheet, template, 0, 2, "0") + _set(sheet, "A1", "부록 — 절편 계산표 없음(대표 단면 없음)") + sheet["인쇄"]["쪽나눔"] = breaks diff --git a/E05_StabReport/E05_StabReport_Store.py b/E05_StabReport/E05_StabReport_Store.py new file mode 100644 index 000000000..d234d8959 --- /dev/null +++ b/E05_StabReport/E05_StabReport_Store.py @@ -0,0 +1,197 @@ +"""E05 보고서 storage — 작업본 · 초기값 · 그림 · 옛 판(요구 347-5 · PLAN 534-3). + +storage(E 프로젝트 루트): + E05_StabReport/report.json 작업본(Workbook · 서버 재계산 `계산값` 포함) + E05_StabReport/report_meta.json 해석 지문 · 만든 때 · 프로그램 몫 시트 서명(덮임 알림용) + E05_StabReport/fig/<열쇠>.png 그림(열쇠 = `fig/<측점 키>.png` · `fig/3d_band.png`) + E05_StabReport/_지난/<때>.json 갱신이 덮은 옛 판(덮인 시트에 사용자 고침이 있었을 때) + initial_snapshot/E05_StabReport/ 초기값 = 처음 만든 직후 한 벌(report · meta · fig) + +시트 채우기 · 그림 SVG = `E05_StabReport_Build.py` · 데이터 3층 = CLAUDE.md 5장. +""" + +from __future__ import annotations + +import hashlib +import shutil +from datetime import datetime +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from common_util.common_util_print_pdf import PrintError +from common_util.common_util_spreadsheet import with_server_values +from common_util.common_util_svg_png import svgs_to_pngs +from E05_StabReport import E05_StabReport_Print as printer +from E05_StabReport.E05_StabReport_Build import ( + META, + PROGRAM_SHEETS, + REPORT, + _read, + build_program, + fig_path, + fill_cover, + form, + gather, + initial_dir, + sheet_sign, + stage_dir, +) + + +def revision(project_root: Path) -> str | None: + path = stage_dir(project_root) / REPORT + return hashlib.sha1(path.read_bytes()).hexdigest()[:16] if path.is_file() else None + + +def load(project_root: Path) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + folder = stage_dir(project_root) + return _read(folder / REPORT), _read(folder / META) + + +def _write_figures(folder: Path, jobs: list[tuple[str, str]]) -> list[str]: + pngs = svgs_to_pngs([svg for _, svg in jobs]) + target = Path(folder) / "fig" + if target.is_dir(): + shutil.rmtree(target) + target.mkdir(parents=True) + for (key, _), png in zip(jobs, pngs): + fig_path(folder, key).write_bytes(png) + return [key for key, _ in jobs] + + +def _write(project_root: Path, book: dict[str, Any], meta: dict[str, Any]) -> None: + folder = stage_dir(project_root) + atomic_write_json(folder / REPORT, book) + atomic_write_json(folder / META, meta) + + +def _numbered(book: dict[str, Any]) -> dict[str, Any]: + """목차 쪽 번호를 실제 쪽 수로(브라우저 없으면 「-」 그대로 · PDF 내보내기 때 다시 셈).""" + try: + printer.number_toc(book) + except PrintError: + pass + return book + + +def _meta(book: dict[str, Any], inputs: dict[str, Any], figures: list[str]) -> dict[str, Any]: + return { + "fingerprint": inputs["fingerprint"], + "built_at": datetime.now().isoformat(timespec="seconds"), + "signs": {s["id"]: sheet_sign(s) for s in book["시트"] if s["id"] in PROGRAM_SHEETS}, + "figures": figures, + } + + +def create(project_root: Path) -> dict[str, Any]: + """처음 만듦 — 양식 전부 · 사용자 몫 처음 채움 · 초기값 한 벌. 돌려줌 = meta.""" + inputs = gather(project_root) + book = form() + fill_cover(next(s for s in book["시트"] if s["id"] == "s1"), inputs) + jobs = build_program(book, inputs) + book = _numbered(with_server_values(book)) + folder = stage_dir(project_root) + figures = _write_figures(folder, jobs) + meta = _meta(book, inputs, figures) + _write(project_root, book, meta) + init = initial_dir(project_root) + if init.is_dir(): + shutil.rmtree(init) + init.mkdir(parents=True) + for name in (REPORT, META): + shutil.copy2(folder / name, init / name) + shutil.copytree(folder / "fig", init / "fig") + return meta + + +def _merge_styles(have: list[dict], form_styles: list[dict], sheets: Any) -> list[dict]: + """새 판 시트의 서식 번호(양식 기준)를 작업본 서식 표 번호로 — 없는 서식은 끝에 붙임 + (옛 작업본 · 사용자가 더한 서식과 번호가 엇갈리지 않게).""" + styles = list(have) + remap: dict[int, int] = {} + for i, style in enumerate(form_styles): + if style not in styles: + styles.append(style) + remap[i] = styles.index(style) + for sheet in sheets: + for cell in sheet["칸"].values(): + if "서식" in cell: + cell["서식"] = remap.get(cell["서식"], cell["서식"]) + return styles + + +def refresh(project_root: Path) -> dict[str, Any]: + """[보고서 갱신] — 프로그램 몫 시트 · 그림만 다시. 사용자 몫 · 사용자가 더한 시트는 그대로. + 덮인 시트에 사용자 고침이 있었으면 `overwritten`(시트 이름) · 옛 판 `_지난/`.""" + book, meta = load(project_root) + if book is None: + return {"created": True, "overwritten": [], **create(project_root)} + inputs = gather(project_root) + fresh = form() + jobs = build_program(fresh, inputs) + built = {s["id"]: s for s in fresh["시트"] if s["id"] in PROGRAM_SHEETS} + book = {**book, "서식": _merge_styles(book.get("서식") or [{}], fresh["서식"], built.values())} + signs = (meta or {}).get("signs") or {} + overwritten = [ + s["이름"] + for s in book["시트"] + if s["id"] in built and signs.get(s["id"]) and sheet_sign(s) != signs[s["id"]] + ] + folder = stage_dir(project_root) + if overwritten: + old = folder / "_지난" + old.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + shutil.copy2(folder / REPORT, old / f"{stamp}.json") + have = {s["id"] for s in book["시트"]} + # 새 판에 없는 프로그램 몫(보강검토 — 저장본 없음)은 뺌 + sheets = [ + built.get(s["id"], s) + for s in book["시트"] + if s["id"] in built or s["id"] not in PROGRAM_SHEETS + ] + order = [s["id"] for s in fresh["시트"]] + for sid in order: # 없는 프로그램 몫(지움 · 새로 생긴 목차 · 부록) = 양식 차례 자리에 + if sid in built and sid not in have: + before = {x for x in order[: order.index(sid)]} + at = max((i + 1 for i, x in enumerate(sheets) if x["id"] in before), default=0) + sheets.insert(at, built[sid]) + book = _numbered(with_server_values({**book, "시트": sheets})) + figures = _write_figures(folder, jobs) + new_meta = _meta(book, inputs, figures) + _write(project_root, book, new_meta) + return {"created": False, "overwritten": overwritten, **new_meta} + + +def reset(project_root: Path) -> bool: + """[초기화] — 초기값을 작업본에 덮어씀(재계산 아님) · 초기값 없으면 False.""" + init = initial_dir(project_root) + if not (init / REPORT).is_file(): + return False + folder = stage_dir(project_root) + folder.mkdir(parents=True, exist_ok=True) + for name in (REPORT, META): + shutil.copy2(init / name, folder / name) + if (folder / "fig").is_dir(): + shutil.rmtree(folder / "fig") + shutil.copytree(init / "fig", folder / "fig") + return True + + +def save(project_root: Path, book: dict[str, Any]) -> None: + """[저장] — 브라우저 `계산값` 은 버리고 서버 풀이로 · meta(지문 · 서명)는 그대로.""" + clean = with_server_values(book) + atomic_write_json(stage_dir(project_root) / REPORT, clean) + + +def figures_of(project_root: Path, book: dict[str, Any]) -> dict[str, bytes]: + """책이 가리키는 그림 열쇠 → PNG(있는 것만) — xlsx · PDF 내보내기용.""" + folder = stage_dir(project_root) + out: dict[str, bytes] = {} + for sheet in book.get("시트") or []: + for fig in sheet.get("그림") or []: + path = fig_path(folder, fig["열쇠"]) + if path.is_file(): + out[fig["열쇠"]] = path.read_bytes() + return out diff --git a/E05_StabReport/E05_StabReport_UI_Page.ts b/E05_StabReport/E05_StabReport_UI_Page.ts new file mode 100644 index 000000000..d700dd885 --- /dev/null +++ b/E05_StabReport/E05_StabReport_UI_Page.ts @@ -0,0 +1,300 @@ +/* ============================================================================= + * E05_StabReport_UI_Page.ts + * E05 안정성평가 보고서(요구서 345 4-3 · 요구 347-5 · PLAN 534-3) — B05~B09 틀(group_workflow). + * GET /api/projects/{id}/stab/report → 작업본(없고 2D 결과가 있으면 서버가 처음 만듦) · 판 · stale. + * 보기 = 내장 엑셀(시트 탭 · 그림 겹판 · 쪽 나눔 점선 · 늦게 불러옴). + * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT {rev, book}(서버 재계산 · 판 다르면 409) · + * [초기화] = POST …/reset · [보고서 갱신] = POST …/refresh(프로그램 몫 시트 · 그림만 다시). + * [엑셀] · [PDF] · [PDF 미리보기] = 화면 지금 책을 서버로(보기만이면 작업본 GET) · 저장 안 함. + * ========================================================================== */ + +import "./E05_StabReport_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 { t } from "@ui/ui_template_locale"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; +import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types"; + +interface ReportMeta { + fingerprint: string; + built_at: string; + figures: string[]; +} + +interface ReportResponse { + book: Workbook | null; + rev?: string | null; + meta?: ReportMeta | null; + stale?: boolean; + has_initial?: boolean; + editable?: boolean; + reason?: string; + overwritten?: string[]; +} + +interface CacheEntry { + rev: string; + book: Workbook; +} + +const cacheKey = (projectId: string): string => `e05_report_cache:${projectId}`; + +async function failure(response: Response): Promise { + const body = (await response.json().catch(() => ({}))) as { detail?: unknown }; + return new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...init, + }); + if (!response.ok) throw await failure(response); + return (await response.json()) as ReportResponse; +} + +function download(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export function renderE05StabReport(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const main = el("div", { className: "e05-page" }); + const staleBar = el("div", { className: "e05-stale" }); + staleBar.hidden = true; + const sheetHost = el("div", { className: "e05-sheet" }); + main.append(staleBar, sheetHost); + const status = el("p", { className: "e05-status" }); + + const button = (label: string, variant: "filled" | "ghost" | "danger-ghost", run: () => void) => + createButton({ label, variant, onClick: run }); + const saveButton = button(t("Common_Btn_Save"), "filled", () => void save()); + const resetButton = button(t("Common_Btn_Reset"), "danger-ghost", () => void reset()); + const refreshButton = button(t("E05_Btn_Refresh"), "ghost", () => void refresh()); + const xlsxButton = button(t("E05_Btn_Xlsx"), "ghost", () => void exportFile("xlsx")); + const pdfButton = button(t("E05_Btn_Pdf"), "ghost", () => void exportFile("pdf")); + const previewButton = button(t("E05_Btn_Preview"), "ghost", () => void preview()); + const staleButton = button(t("E05_Btn_Refresh"), "filled", () => void refresh()); + staleBar.append(el("span", { text: `${t("E05_Stale")} —` }), staleButton); + const all = [saveButton, resetButton, refreshButton, xlsxButton, pdfButton, previewButton]; + for (const b of all) b.disabled = true; // 읽어 온 뒤 켬 + + const exports = el("div", { className: "ui-sidebar-actions" }); + exports.append(xlsxButton, pdfButton, previewButton); + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(refreshButton, resetButton, saveButton); + const panel = el("div", { className: "ui-sidebar-panel e05-panel" }); + panel.append(el("p", { className: "e05-hint", text: t("E05_Note") }), status, exports, actions); + + const frame = document.createElement("iframe"); + frame.className = "e05-preview__frame"; + frame.title = t("E05_Btn_Preview"); + const previewBox = el("div", { className: "e05-preview" }); + previewBox.hidden = true; + let previewUrl: string | null = null; + const closePreview = (): void => { + previewBox.hidden = true; + frame.removeAttribute("src"); + if (previewUrl) URL.revokeObjectURL(previewUrl); + previewUrl = null; + }; + const head = el("div", { className: "e05-preview__head" }); + head.append(button(t("Common_Btn_Close"), "ghost", closePreview)); + previewBox.append(head, frame); + main.append(previewBox); + + mountGroupStep(root, ROUTES.E05_STAB_REPORT, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void sheetHost.append(el("p", { className: "e05-empty", text: t("E05_NoProject") })); + + let sheet: SpreadsheetHandle | null = null; + let state: ReportResponse | null = null; + let editable = false; + let dirty = false; + let busy = false; + + const paint = (): void => { + const lines: string[] = []; + if (!editable) lines.push(t("E05_ReadOnly")); + if (busy) lines.push(t("E05_Working")); + if (dirty) lines.push(t("E05_Unsaved")); + if (state?.meta?.built_at) + lines.push(`${t("E05_BuiltAt")} ${state.meta.built_at.replace("T", " ")}`); + status.textContent = lines.join("\n"); + const ready = Boolean(state?.book) && !busy; + saveButton.disabled = !ready || !editable || !dirty; + resetButton.disabled = !ready || !editable || !state?.has_initial; + refreshButton.disabled = staleButton.disabled = !ready || !editable; + xlsxButton.disabled = pdfButton.disabled = previewButton.disabled = !ready; + staleBar.hidden = !state?.stale || !editable; + }; + + const figureSrc = (key: string): string => + `${API_BASE_URL}/projects/${projectId}/stab/report/fig?key=${encodeURIComponent(key)}` + + `&v=${encodeURIComponent(state?.meta?.built_at ?? "")}`; + + /** 서버 응답 받음 — keepCache 면 같은 판 캐시를 살림 */ + const adopt = async (body: ReportResponse, keepCache: boolean): Promise => { + state = body; + if (body.editable !== undefined) editable = body.editable; + if (!body.book) { + sheet?.destroy(); + sheet = null; + sheetHost.replaceChildren(el("p", { className: "e05-empty", text: body.reason ?? "" })); + dirty = false; + return paint(); + } + let book = body.book; + const raw = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; + const cached = raw ? (JSON.parse(raw) as CacheEntry) : null; + if (cached && cached.rev === body.rev) book = cached.book; + else sessionStorage.removeItem(cacheKey(projectId)); + dirty = Boolean(cached && cached.rev === body.rev); + const { createSpreadsheet } = await import("../A00_Common/spreadsheet/spreadsheet"); + sheet?.destroy(); + sheetHost.replaceChildren(); + sheet = createSpreadsheet(sheetHost, book, { + tabs: true, + readOnly: !editable, + figureSrc, + onChange: (next) => { + dirty = true; + sessionStorage.setItem( + cacheKey(projectId), + JSON.stringify({ rev: state?.rev ?? "", book: next } satisfies CacheEntry), + ); + paint(); + }, + }); + paint(); + }; + + const run = async (task: () => Promise, overlay = false): Promise => { + busy = true; + paint(); + if (overlay) showLoadingOverlay(); + try { + await task(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + if (overlay) hideLoadingOverlay(); + busy = false; + paint(); + } + }; + + const putBook = async (): Promise => { + if (!sheet || !state) return; + const body = await request(`/projects/${projectId}/stab/report`, { + method: "PUT", + body: JSON.stringify({ rev: state.rev, book: sheet.getDoc() }), + }); + sessionStorage.removeItem(cacheKey(projectId)); + state = { ...state, rev: body.rev, meta: body.meta, stale: body.stale }; + dirty = false; + }; + + async function save(): Promise { + if (!editable || !dirty) return; + await run(async () => { + await putBook(); + showToast(t("E05_Saved"), "success"); + }, true); + } + + async function refresh(): Promise { + if (!editable || !state) return; + if ( + dirty && + !(await showConfirmDialog(t("E05_SaveFirst"), t("Common_Btn_Save"), t("Common_Btn_Cancel"))) + ) + return; + await run(async () => { + if (dirty) await putBook(); + const body = await request(`/projects/${projectId}/stab/report/refresh`, { + method: "POST", + body: JSON.stringify({ rev: state?.rev }), + }); + await adopt({ ...body, editable }, false); + const over = body.overwritten ?? []; + if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000); + else showToast(t("E05_Refreshed"), "success"); + }); + } + + async function reset(): Promise { + if (!editable) return; + if ( + !(await showConfirmDialog( + t("E05_ResetConfirm"), + t("Common_Btn_Reset"), + t("Common_Btn_Cancel"), + )) + ) + return; + await run(async () => { + const body = await request(`/projects/${projectId}/stab/report/reset`, { method: "POST" }); + await adopt({ ...body, editable }, false); + showToast(t("E05_ResetDone"), "success"); + }, true); + } + + /** 화면 지금 책(고칠 수 있으면) 또는 작업본(보기만) → 서버가 다시 풀어 만든 파일 */ + async function fetchFile(kind: "xlsx" | "pdf"): Promise { + const url = `${API_BASE_URL}/projects/${projectId}/stab/report/${kind}`; + const init: RequestInit = + editable && sheet + ? { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ book: sheet.getDoc() }), + } + : { credentials: "include" }; + const response = await fetch(url, init); + if (!response.ok) throw await failure(response); + return response.blob(); + } + + async function exportFile(kind: "xlsx" | "pdf"): Promise { + await run(async () => download(await fetchFile(kind), `안정성평가_보고서.${kind}`)); + } + + async function preview(): Promise { + await run(async () => { + const blob = await fetchFile("pdf"); + closePreview(); + previewUrl = URL.createObjectURL(blob); + frame.src = previewUrl; + previewBox.hidden = false; + }); + } + + void run(async () => { + try { + await adopt(await request(`/projects/${projectId}/stab/report`), true); + } catch (error) { + sheetHost.replaceChildren( + el("p", { + className: "e05-empty", + text: error instanceof Error ? error.message : t("Common_Status_Error"), + }), + ); + } + }); +} diff --git a/E05_StabReport/E05_StabReport_UI_Style.css b/E05_StabReport/E05_StabReport_UI_Style.css new file mode 100644 index 000000000..fda06c9da --- /dev/null +++ b/E05_StabReport/E05_StabReport_UI_Style.css @@ -0,0 +1,81 @@ +/* ============================================================================= + * E05_StabReport_UI_Style.css + * E05 보고서 본문 — 위 띠 · 내장 엑셀 · PDF 미리보기 창. 색은 테마 토큰만(밝음 · 어두움 같이) + * ========================================================================== */ + +.e05-page { + display: flex; + flex-direction: column; + height: calc(100dvh - var(--app-header-h)); /* 본문 칸은 min-height 뿐 — 100% 가 안 풀림 */ + min-height: 0; + box-sizing: border-box; +} + +.e05-stale { + display: flex; + align-items: center; + gap: var(--spacing-12); + padding: var(--spacing-8) var(--spacing-16); + background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + color: var(--color-text); + border-bottom: 1px solid var(--color-border); +} + +.e05-stale[hidden] { + display: none; +} + +.e05-sheet { + flex: 1; + min-height: 0; +} + +.e05-empty, +.e05-status, +.e05-hint { + margin: 0; + white-space: pre-line; + color: var(--color-text-muted); +} + +.e05-empty { + padding: var(--spacing-16); +} + +.e05-status.is-error { + color: var(--color-danger); +} + +.e05-panel .ui-sidebar-actions { + flex-wrap: wrap; +} + +.e05-preview { + position: fixed; + inset: 4vh 4vw; + z-index: 50; + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + box-shadow: 0 8px 32px rgb(0 0 0 / 35%); +} + +.e05-preview[hidden] { + display: none; +} + +.e05-preview__head { + display: flex; + justify-content: flex-end; + gap: var(--spacing-8); +} + +.e05-preview__frame { + flex: 1; + width: 100%; + border: 0; + background: #fff; +} diff --git a/E05_StabReport/draft/E05_StabReport_Route.ts b/E05_StabReport/draft/E05_StabReport_Route.ts new file mode 100644 index 000000000..fb762bad2 --- /dev/null +++ b/E05_StabReport/draft/E05_StabReport_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E05_StabReport_Route.ts + * 길 선언(PLAN 529-1 · 534-3) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E05 보고서 페이지 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E05_STAB_REPORT, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E05_StabReport_UI_Page")).renderE05StabReport, +}; diff --git a/E06_StabRock/E06_StabRock_Engine.py b/E06_StabRock/E06_StabRock_Engine.py new file mode 100644 index 000000000..a69b468b3 --- /dev/null +++ b/E06_StabRock/E06_StabRock_Engine.py @@ -0,0 +1,444 @@ +"""E06 암반 비탈면 — Markland 판정 · Hoek-Bray 평면 · 쐐기 안전율 (요구 345 · 351 · PLAN 536-1). + +근거 = 지식DB `original/안정성평가/03`(Markland 부등식 · Hoek-Bray 평면 · 쐐기) · 06(상용 SW) · +Rocscience RocPlane · SWedge 이론서(힘 벡터 · 미끄럼 방향 8 경우 · 수직력 · 지지력은 수동력). +코드는 직접 짬(Dips · RocPlane · SWedge 코드 안 씀) · NumPy · SciPy 만. + +좌표 = 동(+x) · 북(+y) · 위(+z). 면 = (경사방향 α · 경사 ψ) · 선 = (추향 · 선주각). +단위 = 길이 m · 단위중량 kN/m³ · 점착력 kPa · 각 ° · 힘 kN(평면 = 단위 폭당 kN/m). + +⚠ 지식DB 03 의 평면 볼트 항(수직 T cos(θ+ψp) · 활동 −T sin(θ+ψp))은 θ = 볼트와 면 법선 사이 각일 +때 맞음 — 여기는 θ = 볼트 경사(수평 아래)로 받아 힘 벡터로 풂(수직 T sin(θ+ψp) · 활동 −T cos(θ+ψp)). +""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np +from scipy.optimize import minimize_scalar + +GAMMA_W = 9.81 # 물 단위중량 kN/m³ (E03 Types 와 같음) +LATERAL_PLANAR_DEG = 20.0 # 평면 측방 한계(Dips 기본 · Wyllie & Mah) +LATERAL_TOPPLING_DEG = 20.0 # 전도 측방 한계(Dips 기본 · 지식DB 03 은 30 — 화면에서 바꿈) + + +# ---- 방향 ---------------------------------------------------------------------- +def ang_diff(a: float, b: float) -> float: + """두 방위각 사이 최소 각거리 0~180.""" + d = abs((a - b) % 360.0) + return min(d, 360.0 - d) + + +def plane_normal(dip_dir: float, dip: float) -> np.ndarray: + """면의 위쪽 단위 법선.""" + a, p = math.radians(dip_dir), math.radians(dip) + return np.array([math.sin(a) * math.sin(p), math.cos(a) * math.sin(p), math.cos(p)]) + + +def line_vector(trend: float, plunge: float) -> np.ndarray: + """선(추향 · 선주각) → 아래로 향한 단위 벡터.""" + t, p = math.radians(trend), math.radians(plunge) + return np.array([math.sin(t) * math.cos(p), math.cos(t) * math.cos(p), -math.sin(p)]) + + +def vector_line(v: np.ndarray) -> tuple[float, float]: + """벡터 → (추향 · 선주각) 하반구(위로 향하면 뒤집음).""" + v = np.asarray(v, float) + if v[2] > 0: + v = -v + v = v / np.linalg.norm(v) + trend = math.degrees(math.atan2(v[0], v[1])) % 360.0 + return trend, math.degrees(math.asin(max(-1.0, min(1.0, -v[2])))) + + +def pole(dip_dir: float, dip: float) -> tuple[float, float]: + """면의 극점(하반구) = (α + 180 · 90 − ψ).""" + return (dip_dir + 180.0) % 360.0, 90.0 - dip + + +def intersection(a: dict, b: dict) -> tuple[float, float] | None: + """두 면 교선(추향 · 선주각) — 나란하면 None.""" + v = np.cross( + plane_normal(a["dip_direction"], a["dip"]), plane_normal(b["dip_direction"], b["dip"]) + ) + if np.linalg.norm(v) < 1e-9: + return None + return vector_line(v) + + +def apparent_dip(dip: float, dip_dir: float, trend: float) -> float: + """면 (α · ψ) 의 trend 방향 겉보기 경사 — 반대쪽(90° 넘음)이면 음수.""" + return math.degrees( + math.atan(math.tan(math.radians(dip)) * math.cos(math.radians(trend - dip_dir))) + ) + + +def strike_to_dip_direction(strike: float) -> float: + """오른손 법칙 주향 → 경사방향.""" + return (strike + 90.0) % 360.0 + + +# ---- Markland 판정 ------------------------------------------------------------- +def kinematic( + slope: dict, + joints: list[dict], + lateral_planar: float = LATERAL_PLANAR_DEG, + lateral_toppling: float = LATERAL_TOPPLING_DEG, +) -> dict[str, list]: + """slope = {dip_direction, dip} · joints = [{id, dip_direction, dip, phi}, …]. + + 평면 = Δ(αf, αp) ≤ 측방 · φ < ψp < 겉보기 사면 경사 / 쐐기 = 교선 Δ < 90 · φ < 선주각 < + 겉보기 사면 경사(φ = 두 면 중 작은 값) / + 전도 = Δ(αf + 180, αp) ≤ 측방 · ψp > 90 − ψf + φ (Goodman). + """ + af, pf = float(slope["dip_direction"]), float(slope["dip"]) + planar, toppling, wedge = [], [], [] + for j in joints: + aj, pj, phi = float(j["dip_direction"]), float(j["dip"]), float(j["phi"]) + if ang_diff(af, aj) <= lateral_planar and phi < pj < apparent_dip(pf, af, aj): + planar.append(j["id"]) + if ang_diff(af + 180.0, aj) <= lateral_toppling and pj > 90.0 - pf + phi: + toppling.append(j["id"]) + for i, a in enumerate(joints): + for b in joints[i + 1 :]: + line = intersection(a, b) + if line is None: + continue + trend, plunge = line + phi = min(float(a["phi"]), float(b["phi"])) + if ang_diff(af, trend) < 90.0 and phi < plunge < apparent_dip(pf, af, trend): + wedge.append( + { + "ids": [a["id"], b["id"]], + "trend": round(trend, 2), + "plunge": round(plunge, 2), + } + ) + return {"planar": planar, "wedge": wedge, "toppling": toppling} + + +# ---- 평면 파괴 (Hoek-Bray · 2D 단위 폭) ----------------------------------------- +def _plane_block(H: float, pf: float, pp: float, ps: float, crack_x: float | None): + """사면 끝(0,0) · x = 산 쪽 수평 · z = 위. 덩이 다각형 · 활동면 끝 x · 균열 깊이. + + 윗면 = 마루(H cotψf, H)에서 ψs 로 오름. 균열 없음 = 활동면이 윗면과 만나는 점까지.""" + tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps)) + xc = H / tf # 마루 + + def top(x: float) -> float: + return x * tf if x <= xc else H + (x - xc) * ts + + if crack_x is None: + if ts >= tp - 1e-12: + return None # 활동면이 윗면과 안 만남 = 덩이 안 생김 + xe = (H - xc * ts) / (tp - ts) + poly = [(0.0, 0.0), (xc, H), (xe, xe * tp)] + return poly, xe, 0.0 + if crack_x < xc or top(crack_x) - crack_x * tp <= 1e-9: + return None + poly = [(0.0, 0.0), (xc, H), (crack_x, top(crack_x)), (crack_x, crack_x * tp)] + return poly, crack_x, top(crack_x) - crack_x * tp + + +def _area(poly) -> float: + xs, zs = np.array(poly).T + return 0.5 * abs(float(np.dot(xs, np.roll(zs, -1)) - np.dot(zs, np.roll(xs, -1)))) + + +def plane_fs( + *, + H: float, + slope_dip: float, + plane_dip: float, + c: float, + phi: float, + gamma: float, + upper_dip: float = 0.0, + crack: dict | None = None, + water_fill: float = 0.0, + kh: float = 0.0, + bolt_t: float = 0.0, + bolt_plunge: float = 0.0, + gamma_w: float = GAMMA_W, +) -> dict[str, Any] | None: + """평면 파괴 안전율 — 덩이가 안 생기면 None. + + crack = None | {"mode": "depth", "z": m} | {"mode": "critical"}(안전율 최소 자리 찾음) · + 물 = 균열 있음: zw = fill·z · U = ½γw zw A · V = ½γw zw² / 균열 없음: 가운데 최대 수두 + fill·h/2(h = 활동면 오름) · U = ½γw (fill·h/2) A (Hoek · RocPlane) · + kh = 바깥 수평 · 볼트 = 수동력(산 쪽 · 수평 아래 bolt_plunge°).""" + if not 0 < plane_dip < slope_dip < 90 or H <= 0 or upper_dip >= slope_dip: + return None + mode = (crack or {}).get("mode", "none") + if mode == "critical": + return _critical_crack(locals()) + tp = math.tan(math.radians(plane_dip)) + xc = H / math.tan(math.radians(slope_dip)) + crack_x = None + if mode == "depth": + z = float(crack["z"]) + crack_x = _crack_x_for_depth(H, slope_dip, plane_dip, upper_dip, z) + if crack_x is None: + return None + elif mode == "x": + crack_x = xc + float(crack["b"]) + block = _plane_block(H, slope_dip, plane_dip, upper_dip, crack_x) + if block is None: + return None + poly, x_end, z = block + psi = math.radians(plane_dip) + A = x_end / math.cos(psi) + W = gamma * _area(poly) + if crack_x is not None: + zw = water_fill * z + U, V = 0.5 * gamma_w * zw * A, 0.5 * gamma_w * zw * zw + else: + zw = water_fill * (x_end * tp) / 2.0 + U, V = 0.5 * gamma_w * zw * A, 0.0 + d = np.array([-math.cos(psi), -math.sin(psi)]) # 아래로 미끄러짐(바깥 = −x) + n = np.array([-math.sin(psi), math.cos(psi)]) # 면에서 덩이 쪽 + active = np.array([-kh * W - V, -W]) + U * n + drive = float(active @ d) + if drive <= 0: + return None + bolt = bolt_t * np.array( + [math.cos(math.radians(bolt_plunge)), -math.sin(math.radians(bolt_plunge))] + ) + N = max(0.0, -float((active + bolt) @ n)) + resist = c * A + N * math.tan(math.radians(phi)) - float(bolt @ d) + return { + "F": resist / drive, + "W": W, + "A": A, + "U": U, + "V": V, + "z": z, + "zw": zw, + "crack_b": None if crack_x is None else crack_x - xc, + "N": N, + "drive": drive, + "resist": resist, + "polygon": [[round(x, 3), round(y, 3)] for x, y in poly], + } + + +def _crack_x_for_depth(H, pf, pp, ps, z) -> float | None: + """윗면 아래 균열 깊이 z 가 되는 자리(마루 뒤) — 깊이가 마루 틈보다 크면 None.""" + tf, tp, ts = (math.tan(math.radians(v)) for v in (pf, pp, ps)) + xc = H / tf + gap = H - xc * tp # 마루에서 윗면 − 활동면 + if z <= 0 or z >= gap: + return None + if ts >= tp: # 틈이 커지기만 함 = 뒤로 갈수록 깊어짐 + return xc + (z - gap) / (ts - tp) if ts > tp else None + return xc + (gap - z) / (tp - ts) + + +def plane_critical_depth_hoek(H: float, slope_dip: float, plane_dip: float) -> float: + """Hoek-Bray 임계 인장균열 깊이(윗면 수평 · 건조) z = H(1 − √(cotψf tanψp)).""" + return H * ( + 1.0 - math.sqrt(math.tan(math.radians(plane_dip)) / math.tan(math.radians(slope_dip))) + ) + + +def _critical_crack(kw: dict) -> dict | None: + """균열 자리를 마루 뒤로 옮겨 안전율 최소 — 균열 없음보다 크면 균열 없음.""" + args = {k: kw[k] for k in kw if k not in ("crack", "mode")} + tf, tp, ts = (math.tan(math.radians(kw[k])) for k in ("slope_dip", "plane_dip", "upper_dip")) + xc = kw["H"] / tf + x_far = (kw["H"] - xc * ts) / (tp - ts) if ts < tp else xc + 10 * kw["H"] + span = x_far - xc + + def fs_at(b: float) -> float: + r = plane_fs(**args, crack={"mode": "x", "b": b}) + return r["F"] if r else math.inf + + best = minimize_scalar(fs_at, bounds=(span * 1e-3, span * (1 - 1e-3)), method="bounded") + found = plane_fs(**args, crack={"mode": "x", "b": float(best.x)}) + plain = plane_fs(**args, crack=None) + if found is None or (plain is not None and plain["F"] <= found["F"]): + return plain + return found + + +# ---- 쐐기 파괴 (Hoek-Bray · SWedge 꼴 사면체) ------------------------------------ +def _meet(planes: list[tuple[np.ndarray, float]]) -> np.ndarray | None: + """세 면 n·x = d 의 교점.""" + m = np.array([p[0] for p in planes]) + if abs(np.linalg.det(m)) < 1e-10: + return None + return np.linalg.solve(m, np.array([p[1] for p in planes])) + + +def _tri_area(a, b, c) -> float: + return 0.5 * float(np.linalg.norm(np.cross(b - a, c - a))) + + +def wedge_geometry(j1: dict, j2: dict, slope: dict, upper: dict, H: float) -> dict | None: + """사면체 쐐기 — 꼭짓점 O(두 절리 · 사면 = 사면 끝) · A(1 · 사면 · 윗면) · B(2 · 사면 · 윗면) · + C(1 · 2 · 윗면). 사면 높이 H = 사면 위 꼭짓점 A · B 중 높은 쪽의 O 위 높이(SWedge 와 같음 — + Priest 예제 부피 45.201 m³ 재현). 교선이 사면 · 윗면으로 안 나오면 None(쐐기 안 생김).""" + n1 = plane_normal(j1["dip_direction"], j1["dip"]) + n2 = plane_normal(j2["dip_direction"], j2["dip"]) + nf = plane_normal(slope["dip_direction"], slope["dip"]) + nu = plane_normal(upper["dip_direction"], upper["dip"]) + # 윗면 = 사면 위로 H 오른 점을 지남(사면 경사방향 반대로 H cotψf 들어간 점) + back = -line_vector(slope["dip_direction"], 0.0) * (H / math.tan(math.radians(slope["dip"]))) + crest = back + np.array([0.0, 0.0, H]) + du = float(nu @ crest) + O = np.zeros(3) # noqa: E741 — 꼭짓점 이름(SWedge 그림과 같음) + A = _meet([(n1, 0.0), (nf, 0.0), (nu, du)]) + B = _meet([(n2, 0.0), (nf, 0.0), (nu, du)]) + C = _meet([(n1, 0.0), (n2, 0.0), (nu, du)]) + if A is None or B is None or C is None: + return None + # C 는 사면 뒤(산 쪽) · 위 — 교선이 사면으로 나와 윗면에 닿음 + if C[2] <= 1e-9 or float(nf @ C) >= -1e-9 or A[2] <= 1e-9 or B[2] <= 1e-9: + return None + scale = H / max(A[2], B[2]) # 면이 모두 O 를 지나 닮은꼴 — 높이만 맞춤 + A, B, C = A * scale, B * scale, C * scale + volume = abs(float(np.linalg.det(np.array([A - O, B - O, C - O])))) / 6.0 + if volume <= 1e-9: + return None + centroid = (O + A + B + C) / 4.0 + inward = [n if float(n @ centroid) > 0 else -n for n in (n1, n2)] + return { + "vertices": {"O": O, "A": A, "B": B, "C": C}, + "volume": volume, + "areas": [_tri_area(O, A, C), _tri_area(O, B, C)], + "face_area": _tri_area(O, A, B), + "upper_area": _tri_area(A, B, C), + "normals": inward, + "Hw": float(C[2]), + "line_length": float(np.linalg.norm(C)), + } + + +def _unit(v: np.ndarray) -> np.ndarray: + n = float(np.linalg.norm(v)) + return v / n if n > 1e-12 else v * 0.0 + + +def _sliding(A: np.ndarray, n1: np.ndarray, n2: np.ndarray, W: np.ndarray): + """SWedge 미끄럼 방향 — (갈래, 방향). 갈래 = lift · 1 · 2 · 12 · stable.""" + s1 = _unit(np.cross(np.cross(n1, A), n1)) + s2 = _unit(np.cross(np.cross(n2, A), n2)) + c12 = np.cross(n1, n2) + s12 = _unit(c12) * np.sign(float(c12 @ A)) + if A @ n1 > 0 and A @ n2 > 0: + return "lift", _unit(A) + if A @ n1 <= 0 and s1 @ n2 > 0: + return "1", s1 + if A @ n2 <= 0 and s2 @ n1 > 0: + return "2", s2 + if s1 @ n2 <= 0 and s2 @ n1 <= 0: + return "12", s12 + return "stable", None + + +def _normals(mode: str, F: np.ndarray, n1: np.ndarray, n2: np.ndarray) -> tuple[float, float]: + if mode == "1": + return -float(F @ n1), 0.0 + if mode == "2": + return 0.0, -float(F @ n2) + if mode == "12": + c12, c21 = np.cross(n1, n2), np.cross(n2, n1) + return ( + -float(np.cross(F, n2) @ c12) / float(c12 @ c12), + -float(np.cross(F, n1) @ c21) / float(c21 @ c21), + ) + return 0.0, 0.0 + + +def wedge_fs( + *, + j1: dict, + j2: dict, + slope: dict, + upper: dict, + H: float, + gamma: float, + water: dict | None = None, + kh: float = 0.0, + seismic_trend: float | None = None, + external: list[dict] | None = None, + bolt: dict | None = None, + gamma_w: float = GAMMA_W, +) -> dict[str, Any] | None: + """쐐기 안전율(SWedge 이론서 꼴) — 쐐기가 안 생기면 None. + + j = {dip_direction, dip, c, phi} · water = {"fill": p}(중력식 U = p³γw Hw a/6) | + {"pressure": [u1, u2]}(평균 수압 kPa) · kh = 수평(기본 교선 추향) · external = [{force, trend, + plunge}] · bolt = {force, trend, plunge}(수동력). FS = max(지지 전 · 지지 뒤 · 들림).""" + geo = wedge_geometry(j1, j2, slope, upper, H) + if geo is None: + return None + n1, n2 = geo["normals"] + a1, a2 = geo["areas"] + weight = gamma * geo["volume"] + Wv = np.array([0.0, 0.0, -weight]) + active = Wv.copy() + water = water or {} + if "pressure" in water: + u1, u2 = (float(u) for u in water["pressure"]) + else: + p = float(water.get("fill", 0.0)) + u1 = u2 = p**3 * gamma_w * geo["Hw"] / 6.0 + Uf = [u1 * a1 * n1, u2 * a2 * n2] + active += Uf[0] + Uf[1] + line = vector_line(np.cross(n1, n2)) + if kh: + trend = line[0] if seismic_trend is None else seismic_trend + active += kh * weight * line_vector(trend, 0.0) + for item in external or []: + active += float(item["force"]) * line_vector(item["trend"], item["plunge"]) + passive = np.zeros(3) + if bolt and bolt.get("force"): + passive = float(bolt["force"]) * line_vector(bolt["trend"], bolt["plunge"]) + mode, s = _sliding(active, n1, n2, Wv) + out = { + "volume": geo["volume"], + "W": weight, + "areas": [a1, a2], + "face_area": geo["face_area"], + "upper_area": geo["upper_area"], + "Hw": geo["Hw"], + "line_length": geo["line_length"], + "line": [round(line[0], 2), round(line[1], 2)], + "water_force": [float(np.linalg.norm(Uf[0])), float(np.linalg.norm(Uf[1]))], + "mode": mode, + "vertices": {k: [round(float(x), 3) for x in v] for k, v in geo["vertices"].items()}, + } + if mode == "stable": + return {**out, "F": math.inf, "N": [0.0, 0.0], "drive": 0.0, "resist": 0.0} + drive = float(active @ s) + if mode == "lift": + fs = -float(passive @ s) / drive if drive > 0 else math.inf + return {**out, "F": fs, "N": [0.0, 0.0], "drive": drive, "resist": -float(passive @ s)} + joints = (j1, j2) + + def resisting(F: np.ndarray) -> tuple[float, list[float]]: + N = _normals(mode, F, n1, n2) + total = 0.0 + for k, (n_k, a_k) in enumerate(zip(N, (a1, a2))): + if str(k + 1) not in mode or n_k <= 0: + continue # 떨어진 면 = 저항 없음(SWedge 와 같음) + tau = float(joints[k]["c"]) + n_k / a_k * math.tan( + math.radians(float(joints[k]["phi"])) + ) + total += tau * a_k + return total, [max(0.0, n) for n in N] + + j_u, n_u = resisting(active) + fs_u = j_u / drive if drive > 0 else math.inf + fs, n_used, resist = fs_u, n_u, j_u + if np.any(passive): + j_s, n_s = resisting(active + passive) + fs_s = (-float(passive @ s) + j_s) / drive if drive > 0 else math.inf + fs_lift = -float(passive @ _unit(active)) / float(np.linalg.norm(active)) + if fs_s > fs: + fs, n_used, resist = fs_s, n_s, -float(passive @ s) + j_s + fs = max(fs, fs_lift) + return {**out, "F": fs, "N": n_used, "drive": drive, "resist": resist} diff --git a/E06_StabRock/E06_StabRock_Model.py b/E06_StabRock/E06_StabRock_Model.py new file mode 100644 index 000000000..e5db61549 --- /dev/null +++ b/E06_StabRock/E06_StabRock_Model.py @@ -0,0 +1,464 @@ +"""E06 절리 정본 꼴 · 검사 · 측점 해석 묶음 (요구 345 · 351 · PLAN 536-1). + +정본 = `E06_StabRock/joints.json` · 초기값 = `initial_snapshot/E06_StabRock/joints.json` +(절리 없음 + 기본 설정 · 처음 GET 때 한 번). 절리군 한 줄 = 구간(from_m ~ to_m) · 경사방향 · 경사 · +φj · cj · 지속성(선택). 측점 = 복사본 암 측점(리핑암 · 발파암)의 깎기 쪽만. + +사면 = 측점 frame 방향 + 깎기 쪽(경사방향 = 노면 쪽) · 경사 = 깎기(암) 경사비 · 높이 = E03 과 같은 +`side_cases` 높이 · 윗면 = 비탈 끝 바깥 원지반 경사. 물성 · 지진 · 기준안전율 = E02 지반 조건. +""" + +from __future__ import annotations + +import copy +import hashlib +import math +from functools import lru_cache +from pathlib import Path +from typing import Any + +import numpy as np + +from common_util.common_util_station_key import chainage_key +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Model as ground_model +from E03_StabSection.E03_StabSection_Engine_Model import ground_from_cross, side_cases +from E06_StabRock import E06_StabRock_Engine as engine +from E06_StabRock.E06_StabRock_Stereonet import PROJECTIONS, stereonet + +STAGE_DIR = "E06_StabRock" +JOINTS_NAME = "joints.json" +VERSION = 1 +ENGINE_FILES = ("E06_StabRock_Engine.py", "E06_StabRock_Stereonet.py", "E06_StabRock_Model.py") +DEFAULT_GAMMA_R = 25.0 # 암 단위중량 — E02 암 물성이 없을 때(지식DB 07 Hoek & Bray 25~27) +MIN_HEIGHT_M = 1.0 # 이보다 낮은 깎기는 암반 블록 검토 안 함 +CRACK_MODES = ("none", "critical", "depth") +CONDITIONS = ("dry", "wet", "seismic") + +DEFAULT_SETTINGS: dict[str, Any] = { + "projection": "equal_angle", + "lateral_planar_deg": engine.LATERAL_PLANAR_DEG, + "lateral_toppling_deg": engine.LATERAL_TOPPLING_DEG, + "gamma_r": None, # None = E02 측점 암 물성 γt + "min_block_m3": 0.1, # 이보다 작은 블록은 판정에서 뺌(SWedge 최소 쐐기 크기 · 소규모 = 낙석 관리) + "water_fill_wet": 0.5, # 우기 = 균열 깊이 · 활동면 높이 ½(KDS 11 70 05 표 4.3-3 암반) + "crack": {"mode": "none", "z": None}, + "bolt": {"force": 0.0, "plunge": 15.0}, +} + +# 칸 범위 — 화면 입력 · 서버 검사 같은 값 +LIMITS: dict[str, list[float]] = { + "chainage_m": [0.0, 100000.0], + "dip_direction": [0.0, 360.0], + "dip": [0.0, 90.0], + "phi": [0.0, 60.0], + "c": [0.0, 1000.0], + "persistence_m": [0.0, 1000.0], + "lateral_deg": [5.0, 45.0], + "gamma_r": [15.0, 35.0], + "water_fill_wet": [0.0, 1.0], + "min_block_m3": [0.0, 1000.0], + "crack_z": [0.1, 100.0], + "bolt_force": [0.0, 5000.0], + "bolt_plunge": [-30.0, 60.0], +} + +# 기본 절리면 값(새 줄) — 지식DB 07 표 7: cj 0~30 · φj 25~35(Barton 기본마찰각) 보수 쪽 +NEW_SET = {"phi": 30.0, "c": 0.0} + + +def joints_path(project_root: Path) -> Path: + return Path(project_root) / STAGE_DIR / JOINTS_NAME + + +def initial_path(project_root: Path) -> Path: + return Path(project_root) / "initial_snapshot" / STAGE_DIR / JOINTS_NAME + + +def build_initial() -> dict[str, Any]: + return {"version": VERSION, "sets": [], "settings": copy.deepcopy(DEFAULT_SETTINGS)} + + +def upgrade(doc: dict[str, Any]) -> dict[str, Any]: + """빠진 설정 칸만 기본값으로 채운 사본.""" + out = copy.deepcopy(doc) + settings = out.setdefault("settings", {}) + for key, value in DEFAULT_SETTINGS.items(): + settings.setdefault(key, copy.deepcopy(value)) + out.setdefault("sets", []) + return out + + +# ---- 검사 ---------------------------------------------------------------------- +def _num(value: Any) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value) + + +def _in(value: Any, key: str) -> bool: + lo, hi = LIMITS[key] + return _num(value) and lo <= value <= hi + + +def check_joints(doc: dict[str, Any]) -> list[dict[str, str]]: + """틀린 칸 [{path, code}] — code = range · order · name · id_dup · enum · type.""" + errors: list[dict[str, str]] = [] + + def bad(path: str, code: str = "range") -> None: + errors.append({"path": path, "code": code}) + + sets = doc.get("sets") + if not isinstance(sets, list): + return [{"path": "sets", "code": "type"}] + seen: set[str] = set() + for k, row in enumerate(sets): + base = f"sets.{k}" + if not isinstance(row, dict): + bad(base, "type") + continue + ident = str(row.get("id") or "") + if not ident or ident in seen: + bad(f"{base}.id", "id_dup") + seen.add(ident) + if not str(row.get("name") or "").strip(): + bad(f"{base}.name", "name") + for key, limit in ( + ("from_m", "chainage_m"), + ("to_m", "chainage_m"), + ("dip_direction", "dip_direction"), + ("dip", "dip"), + ("phi", "phi"), + ("c", "c"), + ): + if not _in(row.get(key), limit): + bad(f"{base}.{key}") + if _num(row.get("from_m")) and _num(row.get("to_m")) and row["to_m"] < row["from_m"]: + bad(f"{base}.to_m", "order") + if row.get("persistence_m") is not None and not _in(row["persistence_m"], "persistence_m"): + bad(f"{base}.persistence_m") + settings = doc.get("settings") + if not isinstance(settings, dict): + return errors + [{"path": "settings", "code": "type"}] + if settings.get("projection") not in PROJECTIONS: + bad("settings.projection", "enum") + for key in ("lateral_planar_deg", "lateral_toppling_deg"): + if not _in(settings.get(key), "lateral_deg"): + bad(f"settings.{key}") + if settings.get("gamma_r") is not None and not _in(settings["gamma_r"], "gamma_r"): + bad("settings.gamma_r") + if not _in(settings.get("water_fill_wet"), "water_fill_wet"): + bad("settings.water_fill_wet") + if not _in(settings.get("min_block_m3"), "min_block_m3"): + bad("settings.min_block_m3") + crack = settings.get("crack") if isinstance(settings.get("crack"), dict) else {} + if crack.get("mode") not in CRACK_MODES: + bad("settings.crack.mode", "enum") + elif crack["mode"] == "depth" and not _in(crack.get("z"), "crack_z"): + bad("settings.crack.z") + bolt = settings.get("bolt") if isinstance(settings.get("bolt"), dict) else {} + if not _in(bolt.get("force"), "bolt_force"): + bad("settings.bolt.force") + if not _in(bolt.get("plunge"), "bolt_plunge"): + bad("settings.bolt.plunge") + return errors + + +def clean(doc: dict[str, Any]) -> dict[str, Any]: + """정본 칸만 · 숫자는 float.""" + sets = [] + for row in doc["sets"]: + item = { + "id": str(row["id"]), + "name": str(row["name"]).strip(), + **{ + key: float(row[key]) + for key in ("from_m", "to_m", "dip_direction", "dip", "phi", "c") + }, + "persistence_m": None + if row.get("persistence_m") is None + else float(row["persistence_m"]), + } + item["dip_direction"] %= 360.0 + sets.append(item) + s = doc["settings"] + crack = s["crack"] + return { + "version": VERSION, + "sets": sets, + "settings": { + "projection": s["projection"], + "lateral_planar_deg": float(s["lateral_planar_deg"]), + "lateral_toppling_deg": float(s["lateral_toppling_deg"]), + "gamma_r": None if s.get("gamma_r") is None else float(s["gamma_r"]), + "water_fill_wet": float(s["water_fill_wet"]), + "min_block_m3": float(s["min_block_m3"]), + "crack": { + "mode": crack["mode"], + "z": float(crack["z"]) if crack["mode"] == "depth" else None, + }, + "bolt": {"force": float(s["bolt"]["force"]), "plunge": float(s["bolt"]["plunge"])}, + }, + } + + +# ---- 측점 사면 ------------------------------------------------------------------ +def rock_stations(source: Path) -> list[dict[str, Any]]: + """암 측점(리핑암 · 발파암).""" + return [s for s in src.stations(source) if s.get("ground_type") in ground_model.ROCK_LABELS] + + +def _upper_dip(ground: list, start: float, sign: float, H: float, face_dip: float) -> float: + """비탈 끝 바깥 max(H, 5 m) 원지반 평균 경사(오르막만 · 사면 경사 − 1° 아래).""" + gx = np.array([p[0] for p in ground]) + gz = np.array([p[1] for p in ground]) + end = start + sign * max(H, 5.0) + end = min(max(end, gx[0]), gx[-1]) + run = abs(end - start) + if run < 0.5: + return 0.0 + rise = float(np.interp(end, gx, gz) - np.interp(start, gx, gz)) + return float(min(max(0.0, math.degrees(math.atan2(rise, run))), face_dip - 1.0)) + + +def station_slopes(cross: dict, design: dict) -> list[dict[str, Any]]: + """측점의 깎기 쪽마다 사면 — {side, dip_direction, dip, height_m, upper_dip}.""" + ground = ground_from_cross(cross) + line = sorted( + (float(p["offset_m"]), float(p["elevation_m"])) for p in design.get("design_line") or [] + ) + ratio = design.get("cut_slope_ratio") + if len(ground) < 2 or len(line) < 2 or not ratio: + return [] + face_dip = math.degrees(math.atan2(1.0, float(ratio))) + left = np.array([float(v) for v in cross["frame"]["left_xy"]]) + out = [] + for case in side_cases(design, ground, line): + if case["role"] != "cut" or case["height_m"] < MIN_HEIGHT_M: + continue + side = case["side"] + sign = 1.0 if side == "left" else -1.0 + # 비탈 끝 = 탐색 끝에서 되짚음(side_cases: reach = 비탈 끝 + sign · max(5, 2H)) + day = case["limits"][1 if sign > 0 else 0] - sign * max(5.0, 2.0 * case["height_m"]) + face = -sign * left # 노면 쪽으로 기움 + dip_dir = math.degrees(math.atan2(face[0], face[1])) % 360.0 + out.append( + { + "side": side, + "dip_direction": round(dip_dir, 2), + "dip": round(face_dip, 2), + "height_m": round(case["height_m"], 3), + "upper_dip": round(_upper_dip(ground, day, sign, case["height_m"], face_dip), 2), + } + ) + return out + + +# ---- 해석 ---------------------------------------------------------------------- +@lru_cache(maxsize=1) +def engine_hash() -> str: + digest = hashlib.sha1() + for name in ENGINE_FILES: + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def _ground_inputs(ground: dict[str, Any]) -> dict[str, Any]: + """E02 에서 E06 이 쓰는 칸 — 지문에도 이것만.""" + return { + k: ground.get(k) + for k in ("materials", "label_map", "stations", "zones", "safety_factors", "seismic") + } + + +def _gamma_for(resolved: dict | None, materials: dict, override: float | None) -> float: + if override: + return float(override) + mat = materials.get((resolved or {}).get("lower") or (resolved or {}).get("upper")) + return float(mat["gamma_t"]) if mat else DEFAULT_GAMMA_R + + +def _fmt(value: float | None) -> float | None: + if value is None or not math.isfinite(value): + return None + return round(value, 4) + 0.0 # -0.0 → 0.0(들림 · 볼트 없음) + + +def _cases_for(slope: dict, joints: list[dict], kin: dict, settings: dict, gamma: float, conds): + """위험 모드만 안전율 — 조건 = [(이름, 수위 비, kh)].""" + upper = {"dip_direction": slope["dip_direction"], "dip": slope["upper_dip"]} + by_id = {j["id"]: j for j in joints} + bolt = settings["bolt"] + min_block = settings["min_block_m3"] + crack = settings["crack"] if settings["crack"]["mode"] != "none" else None + out = [] + for jid in kin["planar"]: + j = by_id[jid] + for name, fill, kh in conds: + r = engine.plane_fs( + H=slope["height_m"], + slope_dip=slope["dip"], + plane_dip=j["dip"], + c=j["c"], + phi=j["phi"], + gamma=gamma, + upper_dip=slope["upper_dip"], + crack=crack, + water_fill=fill, + kh=kh, + bolt_t=bolt["force"], + bolt_plunge=bolt["plunge"], + ) + short = ( + r is not None and j.get("persistence_m") is not None and j["persistence_m"] < r["A"] + ) + volume = r["W"] / gamma if r else None # 평면 = 폭 1 m 당 면적(m²) → m³ + out.append( + { + "mode": "planar", + "ids": [jid], + "condition": name, + "F": _fmt(r["F"]) if r else None, + "formed": r is not None, + "persistence_short": short, + "volume": _fmt(volume), + "small": volume is not None and volume < min_block, + "W": _fmt(r["W"]) if r else None, + "A": _fmt(r["A"]) if r else None, + "z": _fmt(r["z"]) if r else None, + } + ) + for item in kin["wedge"]: + a, b = (by_id[k] for k in item["ids"]) + for name, fill, kh in conds: + r = engine.wedge_fs( + j1=a, + j2=b, + slope=slope, + upper=upper, + H=slope["height_m"], + gamma=gamma, + water={"fill": fill}, + kh=kh, + bolt={ + "force": bolt["force"], + "trend": (slope["dip_direction"] + 180.0) % 360.0, + "plunge": bolt["plunge"], + }, + ) + length = r["line_length"] if r else None + short = any( + j.get("persistence_m") is not None + and length is not None + and j["persistence_m"] < length + for j in (a, b) + ) + out.append( + { + "mode": "wedge", + "ids": list(item["ids"]), + "condition": name, + "F": _fmt(r["F"]) if r else None, + "formed": r is not None, + "persistence_short": short, + "sliding": r["mode"] if r else None, + "W": _fmt(r["W"]) if r else None, + "volume": _fmt(r["volume"]) if r else None, + "small": r is not None and r["volume"] < min_block, + } + ) + return out + + +def analyze(source: Path, joints_doc: dict, ground: dict) -> list[dict[str, Any]]: + """암 측점 × 깎기 쪽 — 사면 · 절리 · Markland · 안전율 · 판정.""" + settings = joints_doc["settings"] + sets = joints_doc["sets"] + materials = {m["id"]: m for m in ground.get("materials") or []} + resolved = {chainage_key(r["chainage_m"]): r for r in ground_model.resolve_stations(ground)} + seismic = ground.get("seismic") or {} + factors = ground.get("safety_factors") or {} + conds = [("dry", 0.0, 0.0), ("wet", float(settings["water_fill_wet"]), 0.0)] + if seismic.get("enabled") and seismic.get("kh"): + conds.append(("seismic", 0.0, float(seismic["kh"]))) + out = [] + for station in rock_stations(source): + ch = float(station["chainage_m"]) + key = chainage_key(ch) + joints = [j for j in sets if j["from_m"] <= ch <= j["to_m"]] + cross = src.cross_section(source, ch) + gamma = _gamma_for(resolved.get(key), materials, settings.get("gamma_r")) + for slope in station_slopes(cross, cross["design"]): + kin = engine.kinematic( + slope, joints, settings["lateral_planar_deg"], settings["lateral_toppling_deg"] + ) + cases = _cases_for(slope, joints, kin, settings, gamma, conds) + for case in cases: + need = (factors.get(case["condition"]) or {}).get("cut") + case["required"] = need + case["ok"] = ( + None + if case["F"] is None or need is None or case["small"] + else case["F"] >= need + ) + out.append( + { + "key": key, + "chainage_m": ch, + "label": station.get("label"), + "ground_type": station.get("ground_type"), + "slope": slope, + "gamma_r": gamma, + "joints": [ + {k: j[k] for k in ("id", "name", "dip_direction", "dip", "phi", "c")} + for j in joints + ], + "kinematic": kin, + "cases": cases, + **_verdict(kin, cases), + } + ) + return out + + +def _verdict(kin: dict, cases: list[dict]) -> dict[str, Any]: + """판정 — NG(기준 미달 하나라도) · check(전도만 · 블록 안 생김) · OK · none(위험 모드 없음 · + 블록이 모두 소규모). 소규모 블록(최소 블록 크기 미만)은 판정 · 최소 Fs 에서 뺌.""" + judged = [c for c in cases if not c["small"]] + values = [c["F"] for c in judged if c["F"] is not None] + min_f = min(values) if values else None + if any(c["ok"] is False for c in judged): + verdict = "NG" + elif kin["toppling"] or (judged and not values): + verdict = "check" + elif judged: + verdict = "OK" + else: + verdict = "none" + small = {(c["mode"], tuple(c["ids"])) for c in cases if c["small"]} + return { + "small_blocks": len(small), + "counts": { + "planar": len(kin["planar"]), + "wedge": len(kin["wedge"]), + "toppling": len(kin["toppling"]), + }, + "min_F": min_f, + "verdict": verdict, + } + + +def station_net(entry: dict, settings: dict) -> dict[str, Any]: + """한 측점 쪽 스테레오넷(해석 때 절리 그대로) — 두 투영 모두(화면이 고름).""" + joints = entry["joints"] + phi = min((j["phi"] for j in joints), default=NEW_SET["phi"]) + nets = { + kind: stereonet( + entry["slope"], + joints, + phi, + kind, + settings["lateral_planar_deg"], + settings["lateral_toppling_deg"], + ) + for kind in PROJECTIONS + } + return {**nets, "phi": phi} diff --git a/E06_StabRock/E06_StabRock_Router.py b/E06_StabRock/E06_StabRock_Router.py new file mode 100644 index 000000000..ee9a5e44b --- /dev/null +++ b/E06_StabRock/E06_StabRock_Router.py @@ -0,0 +1,206 @@ +"""E06 암반 평사투영 해석 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1). + +- `GET /api/projects/{id}/stab/rock` — 절리 작업본(없으면 초기값) · 범위 · 암 측점 · 고칠 수 있는지 +- `PUT …/stab/rock` — [저장] 검사 뒤 작업본 `E06_StabRock/joints.json` +- `POST …/stab/rock/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) +- `POST …/stab/rock/analyze` — [해석] 암 측점 × 깎기 쪽 · 지문이 같으면 안 셈(`fresh`) +- `GET …/stab/rock/result` — 측점별 모드 · 안전율 · 판정(+ 지문이 지금과 다른지) +- `GET …/stab/rock/result?station=&side=` — 그 측점 쪽 + 스테레오넷(등각 · 등면적) + +입력 = E01 복사본 + E02 지반 조건(물성 · 지진 · 기준안전율) + 절리 작업본(저장 안 한 캐시는 안 씀). +계산 자리 = ③ 서버 단독. 결과 = 파생 저장본 `_derived/stab_rock/<지문>`. +공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from common_util.common_util_auth import verify_session +from common_util.common_util_derived_store import ( + derived_dir, + derived_key, + read_derived, + write_derived, +) +from common_util.common_util_json import atomic_write_json +from E01_StabRegister import E01_StabRegister_Source as src +from E02_StabGround import E02_StabGround_Router as e02 +from E06_StabRock import E06_StabRock_Model as model + +router = APIRouter(prefix="/api/projects", tags=["E06 Stab Rock"]) + +KIND = "stab_rock" + + +class JointsRequest(BaseModel): + joints: dict[str, Any] + + +def ensure_initial(project_root: Path) -> dict[str, Any]: + """초기값이 없을 때만 한 번 — 절리 없음 + 기본 설정.""" + path = model.initial_path(project_root) + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + doc = model.build_initial() + atomic_write_json(path, doc) + return doc + + +def _working(project_root: Path) -> dict[str, Any] | None: + path = model.joints_path(project_root) + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def _joints(project_root: Path) -> dict[str, Any]: + return model.upgrade(_working(project_root) or ensure_initial(project_root)) + + +def _source(project_root: Path) -> Path: + root = src.source_dir(project_root) + if not (root / src.MANIFEST_NAME).is_file(): + raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.") + return root + + +def _fingerprint(project_root: Path, source: Path) -> tuple[str, dict, dict]: + joints = _joints(project_root) + ground = e02._read_working(project_root) or e02.ensure_initial(project_root) + fp = derived_key( + model.engine_hash(), + src.manifest(source)["source_fingerprint"], + { + "sets": joints["sets"], + "settings": {k: v for k, v in joints["settings"].items() if k != "projection"}, + }, + model._ground_inputs(ground), + ) + return fp, joints, ground + + +def _latest(project_root: Path) -> dict[str, Any] | None: + """가장 최근 저장본(지문이 달라도 — 화면이 「다시 해석」 띠를 띄움).""" + folder = derived_dir(project_root, KIND) + files = sorted(folder.glob("*.json"), key=lambda p: p.stat().st_mtime_ns, reverse=True) + for path in files: + saved = read_derived(project_root, KIND, path.stem) + if saved: + return saved + return None + + +@router.get("/{project_id}/stab/rock") +async def get_rock( + project_id: UUID, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root, project = await e02._project(project_id) + initial = ensure_initial(root) + working = _working(root) + try: + stations = [ + {"chainage_m": s["chainage_m"], "label": s["label"], "ground_type": s["ground_type"]} + for s in model.rock_stations(_source(root)) + ] + except HTTPException: + stations = [] + return { + "joints": model.upgrade(working or initial), + "saved": working is not None, + "defaults": { + "settings": model.DEFAULT_SETTINGS, + "new_set": model.NEW_SET, + "limits": model.LIMITS, + }, + "stations": stations, + "editable": e02._editable(session, project), + } + + +@router.put("/{project_id}/stab/rock") +async def put_rock(project_id: UUID, payload: JointsRequest) -> dict[str, Any]: + root, _ = await e02._project(project_id) + errors = model.check_joints(payload.joints) + if errors: + raise HTTPException(status_code=422, detail={"errors": errors}) + doc = model.clean(payload.joints) + atomic_write_json(model.joints_path(root), doc) + return {"joints": doc} + + +@router.post("/{project_id}/stab/rock/reset") +async def reset_rock(project_id: UUID) -> dict[str, Any]: + root, _ = await e02._project(project_id) + initial = ensure_initial(root) + atomic_write_json(model.joints_path(root), initial) + return {"joints": model.upgrade(initial)} + + +def _summary(stations: list[dict[str, Any]]) -> dict[str, Any]: + verdicts: dict[str, int] = {} + for item in stations: + verdicts[item["verdict"]] = verdicts.get(item["verdict"], 0) + 1 + return { + "slopes": len(stations), + "verdicts": verdicts, + "small_blocks": sum(item["small_blocks"] for item in stations), + "modes": { + mode: sum(item["counts"][mode] for item in stations) + for mode in ("planar", "wedge", "toppling") + }, + } + + +def _run(root: Path) -> dict[str, Any]: + source = _source(root) + fp, joints, ground = _fingerprint(root, source) + if read_derived(root, KIND, fp): + return {"status": "fresh", "fingerprint": fp} + stations = model.analyze(source, joints, ground) + value = { + "fingerprint": fp, + "settings": joints["settings"], + "sets": joints["sets"], + "summary": _summary(stations), + "stations": stations, + } + write_derived(root, KIND, fp, value) + return {"status": "done", "fingerprint": fp, "summary": value["summary"]} + + +@router.post("/{project_id}/stab/rock/analyze") +async def analyze_rock(project_id: UUID) -> dict[str, Any]: + """[해석] — 암 측점 수십 개 · 1 초 안쪽이라 바로 셈(진행률 없음).""" + root, _ = await e02._project(project_id) + return await asyncio.to_thread(_run, root) + + +def _result(root: Path, station: str | None, side: str | None) -> dict[str, Any]: + source = _source(root) + fp, _, _ = _fingerprint(root, source) + saved = read_derived(root, KIND, fp) or _latest(root) + if not saved: + return {"status": "empty", "stale": False, "result": None} + value = saved["value"] + stale = value["fingerprint"] != fp + if station is None: + return {"status": "success", "stale": stale, "result": value} + for entry in value["stations"]: + if entry["key"] == station and (side is None or entry["slope"]["side"] == side): + net = model.station_net(entry, value["settings"]) + return {"status": "success", "stale": stale, "entry": entry, "stereonet": net} + raise HTTPException(status_code=404, detail="그 측점의 암반 해석 결과가 없습니다.") + + +@router.get("/{project_id}/stab/rock/result") +async def result_rock( + project_id: UUID, station: str | None = None, side: str | None = None +) -> dict[str, Any]: + root, _ = await e02._project(project_id) + return await asyncio.to_thread(_result, root, station, side) diff --git a/E06_StabRock/E06_StabRock_Stereonet.py b/E06_StabRock/E06_StabRock_Stereonet.py new file mode 100644 index 000000000..fb0b3cd3e --- /dev/null +++ b/E06_StabRock/E06_StabRock_Stereonet.py @@ -0,0 +1,137 @@ +"""E06 스테레오넷 투영 좌표 — 하반구 · 등각(Wulff) · 등면적(Schmidt) (PLAN 536-1). + +화면(SVG)은 이 좌표를 그리기만 함 — 반지름 1 원 · x = 동 · y = 북(화면은 y 를 뒤집음). +극점 · 대원 · 교선 · 마찰원 · 일광 엔벨로프 · 위험 영역(Markland 부등식과 같은 경계 — +`E06_StabRock_Engine.kinematic`)을 냄. 투영식 = 표준 구면 투영(mplstereonet 등은 대조만). +""" + +from __future__ import annotations + +import math +from typing import Any + +from E06_StabRock.E06_StabRock_Engine import ( + LATERAL_PLANAR_DEG, + LATERAL_TOPPLING_DEG, + apparent_dip, + intersection, + pole, +) + +PROJECTIONS = ("equal_angle", "equal_area") +STEP_DEG = 2.0 # 곡선 표본 간격 + + +def radius(plunge: float, kind: str) -> float: + """선주각 → 중심에서 거리(0 = 연직 · 1 = 수평).""" + half = math.radians(90.0 - plunge) / 2.0 + return math.tan(half) if kind == "equal_angle" else math.sqrt(2.0) * math.sin(half) + + +def project(trend: float, plunge: float, kind: str) -> list[float]: + r = radius(plunge, kind) + t = math.radians(trend) + return [round(r * math.sin(t), 5), round(r * math.cos(t), 5)] + + +def inverse(x: float, y: float, kind: str) -> tuple[float, float]: + """투영 좌표 → (추향 · 선주각) — 왕복 시험 · 화면 마우스 자리 읽기.""" + r = math.hypot(x, y) + half = math.atan(r) if kind == "equal_angle" else math.asin(min(1.0, r / math.sqrt(2.0))) + return math.degrees(math.atan2(x, y)) % 360.0, 90.0 - math.degrees(2.0 * half) + + +def _samples(lo: float, hi: float) -> list[float]: + n = max(2, int(math.ceil((hi - lo) / STEP_DEG)) + 1) + return [lo + (hi - lo) * k / (n - 1) for k in range(n)] + + +def great_circle(dip_dir: float, dip: float, kind: str) -> list[list[float]]: + """면의 대원 — 주향 한끝 → 경사 방향 → 다른 끝.""" + return [ + project(dip_dir + b, max(0.0, apparent_dip(dip, dip_dir, dip_dir + b)), kind) + for b in _samples(-90.0, 90.0) + ] + + +def _zone(trend_lo, trend_hi, plunge_lo, plunge_hi, kind) -> list[list[float]] | None: + """(추향 · 선주각) 띠 → 닫힌 다각형 — plunge_lo/hi 는 추향의 함수 · 비면 None.""" + ts = _samples(trend_lo, trend_hi) + lows = [plunge_lo(t) for t in ts] + highs = [plunge_hi(t) for t in ts] + keep = [k for k in range(len(ts)) if highs[k] > lows[k]] + if len(keep) < 2: + return None + outer = [project(ts[k], lows[k], kind) for k in keep] + inner = [project(ts[k], highs[k], kind) for k in reversed(keep)] + return outer + inner + + +def stereonet( + slope: dict, + joints: list[dict], + phi: float, + kind: str = "equal_angle", + lateral_planar: float = LATERAL_PLANAR_DEG, + lateral_toppling: float = LATERAL_TOPPLING_DEG, +) -> dict[str, Any]: + """slope = {dip_direction, dip} · joints = [{id, dip_direction, dip}] · phi = 영역 마찰각.""" + af, pf = float(slope["dip_direction"]), float(slope["dip"]) + + def face_app(t: float) -> float: + return apparent_dip(pf, af, t) + + planes = [] + for j in joints: + tr, pl = pole(j["dip_direction"], j["dip"]) + planes.append( + { + "id": j["id"], + "pole": project(tr, pl, kind), + "great_circle": great_circle(j["dip_direction"], j["dip"], kind), + } + ) + lines = [] + for i, a in enumerate(joints): + for b in joints[i + 1 :]: + line = intersection(a, b) + if line is not None: + lines.append({"ids": [a["id"], b["id"]], "point": project(*line, kind)}) + # 일광 엔벨로프(극점 꼴) = 사면 겉보기 경사로 기운 면들의 극점 + envelope = [ + project(af + b + 180.0, 90.0 - face_app(af + b), kind) for b in _samples(-90.0, 90.0) + ] + zones = { + # 평면(극점) — 극점 추향 αf + 180 ± 측방 · 선주각 90 − ψapp ~ 90 − φ + "planar": _zone( + af + 180.0 - lateral_planar, + af + 180.0 + lateral_planar, + lambda t: 90.0 - face_app(t - 180.0), + lambda t: 90.0 - phi, + kind, + ), + # 쐐기(교선) — 추향 αf ± 90 · 선주각 φ ~ ψapp + "wedge": _zone(af - 90.0, af + 90.0, lambda t: phi, face_app, kind), + # 전도(극점) — 극점 추향 αf ± 측방 · 선주각 0 ~ ψf − φ + "toppling": _zone( + af - lateral_toppling, + af + lateral_toppling, + lambda t: 0.0, + lambda t: pf - phi, + kind, + ), + } + return { + "projection": kind, + "slope": { + "pole": project(*pole(af, pf), kind), + "great_circle": great_circle(af, pf, kind), + }, + "planes": planes, + "intersections": lines, + # 마찰원 — 극점 꼴 = 중심에서 φ(선주각 90 − φ) · 교선 꼴 = 선주각 φ + "friction_pole_r": round(radius(90.0 - phi, kind), 5), + "friction_line_r": round(radius(phi, kind), 5), + "daylight_envelope": envelope, + "zones": zones, + } diff --git a/E06_StabRock/E06_StabRock_UI_Joints.ts b/E06_StabRock/E06_StabRock_UI_Joints.ts new file mode 100644 index 000000000..d64d4fecb --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Joints.ts @@ -0,0 +1,331 @@ +/* ============================================================================= + * E06_StabRock_UI_Joints.ts + * E06 좌측 — 절리군 표(구간 · 경사방향 · 경사 · φj · cj · 지속성 · [추가] [지움]) + 해석 설정 + * (측방 한계 · 암 단위중량 · 우기 수위 · 인장균열 · 볼트). 칸을 바꾸면 doc 을 고치고 onChange — + * 캐시 · 저장은 페이지 몫. 범위 = 서버 LIMITS(벗어나면 칸 빨강 · 마우스 글). + * ========================================================================== */ + +import { createPanelCard } from "@ui/ui_template_collapsible"; +import { createButton, el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +export interface JointSet { + id: string; + name: string; + from_m: number; + to_m: number; + dip_direction: number; + dip: number; + phi: number; + c: number; + persistence_m: number | null; +} + +export type Projection = "equal_angle" | "equal_area"; + +export interface RockSettings { + projection: Projection; + lateral_planar_deg: number; + lateral_toppling_deg: number; + gamma_r: number | null; + water_fill_wet: number; + min_block_m3: number; + crack: { mode: "none" | "critical" | "depth"; z: number | null }; + bolt: { force: number; plunge: number }; +} + +export interface JointsDoc { + version: number; + sets: JointSet[]; + settings: RockSettings; +} + +export interface RockDefaults { + settings: RockSettings; + new_set: { phi: number; c: number }; + limits: Record; +} + +export interface JointsView { + doc: JointsDoc; + defaults: RockDefaults; + editable: boolean; + endM: number; // 마지막 암 측점(새 줄 구간 끝) + change: () => void; // 값만 바뀜 + rebuild: () => void; // 줄 수가 바뀜 +} + +/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 is-invalid · 바꾸면 set 부르고 change */ +function numberInput( + view: JointsView, + value: number | null, + limit: string, + set: (v: number | null) => void, + opts: { optional?: boolean; step?: string; path: string }, +): HTMLInputElement { + const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity]; + const input = el("input", { + className: "e06-input", + attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path }, + }) as HTMLInputElement; + input.value = value === null ? "" : String(value); + input.disabled = !view.editable; + const check = (): boolean => { + const raw = input.value.trim(); + const num = Number(raw); + const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi; + input.classList.toggle("is-invalid", !ok); + input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`; + return ok; + }; + input.addEventListener("input", () => { + const raw = input.value.trim(); + set(raw === "" ? null : Number(raw)); + check(); + view.change(); + }); + check(); + return input; +} + +function nextId(sets: JointSet[]): string { + let k = sets.length + 1; + while (sets.some((s) => s.id === `J${k}`)) k += 1; + return `J${k}`; +} + +const COLUMNS: LocaleKey[] = [ + "E06_Col_Name", + "E06_Col_From", + "E06_Col_To", + "E06_Col_DipDir", + "E06_Col_Dip", + "E06_Col_Phi", + "E06_Col_C", + "E06_Col_Persist", +]; + +function jointTable(view: JointsView): HTMLElement { + const wrap = el("div", { className: "e06-table-wrap" }); + const table = el("table", { className: "e06-table" }); + const head = el("tr"); + for (const key of COLUMNS) head.append(el("th", { text: t(key) })); + head.append(el("th")); + table.append(el("thead"), el("tbody")); + table.tHead!.append(head); + view.doc.sets.forEach((row, k) => { + const tr = el("tr", { attrs: { "data-id": row.id } }); + const name = el("input", { + className: "e06-input e06-input--name", + attrs: { type: "text", "data-path": `sets.${k}.name` }, + }) as HTMLInputElement; + name.value = row.name; + name.disabled = !view.editable; + name.addEventListener("input", () => { + row.name = name.value; + name.classList.toggle("is-invalid", !name.value.trim()); + view.change(); + }); + tr.append(el("td", { children: [name] })); + const fields: [keyof JointSet, string, boolean][] = [ + ["from_m", "chainage_m", false], + ["to_m", "chainage_m", false], + ["dip_direction", "dip_direction", false], + ["dip", "dip", false], + ["phi", "phi", false], + ["c", "c", false], + ["persistence_m", "persistence_m", true], + ]; + for (const [key, limit, optional] of fields) { + const input = numberInput( + view, + row[key] as number | null, + limit, + (v) => ((row as unknown as Record)[key] = v), + { optional, path: `sets.${k}.${key}` }, + ); + tr.append(el("td", { children: [input] })); + } + const remove = createButton({ + label: "×", + variant: "danger-ghost", + size: "sm", + onClick: () => { + view.doc.sets.splice(k, 1); + view.rebuild(); + }, + }); + remove.title = t("E06_Remove"); + remove.disabled = !view.editable; + tr.append(el("td", { children: [remove] })); + table.tBodies[0].append(tr); + }); + wrap.append(table); + if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") })); + return wrap; +} + +function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement { + const row = el("label", { className: "e06-setting" }); + row.append(el("span", { text: t(label) }), control); + return row; +} + +function select( + view: JointsView, + value: T, + options: [T, LocaleKey][], + set: (v: T) => void, + path: string, +): HTMLSelectElement { + const box = el("select", { + className: "e06-input", + attrs: { "data-path": path }, + }) as HTMLSelectElement; + for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } })); + box.value = value; + box.disabled = !view.editable; + box.addEventListener("change", () => { + set(box.value as T); + view.rebuild(); + }); + return box; +} + +/** 최소 블록 크기 칸 + [기본값] — 기본값 = 근거 후보(사용자 확정 전) */ +function minBlockControl(view: JointsView, s: RockSettings): HTMLElement { + const reset = createButton({ + label: t("E06_Defaults"), + variant: "ghost", + size: "sm", + onClick: () => { + s.min_block_m3 = view.defaults.settings.min_block_m3; + view.change(); + view.rebuild(); + }, + }); + reset.disabled = !view.editable; + const box = el("span", { className: "e06-setting__ctl" }); + box.append( + numberInput(view, s.min_block_m3, "min_block_m3", (v) => (s.min_block_m3 = v ?? NaN), { + step: "0.01", + path: "settings.min_block_m3", + }), + reset, + ); + return box; +} + +export function buildJointCards(view: JointsView): HTMLElement[] { + const s = view.doc.settings; + const setsCard = createPanelCard(t("E06_Card_Joints"), false); + const add = createButton({ + label: t("E06_Add"), + variant: "ghost", + size: "sm", + onClick: () => { + const id = nextId(view.doc.sets); + view.doc.sets.push({ + id, + name: id, + from_m: 0, + to_m: Math.ceil(view.endM), + dip_direction: 0, + dip: 45, + phi: view.defaults.new_set.phi, + c: view.defaults.new_set.c, + persistence_m: null, + }); + view.rebuild(); + }, + }); + add.disabled = !view.editable; + setsCard.append( + el("p", { className: "e06-hint", text: t("E06_JointsHint") }), + jointTable(view), + el("div", { className: "e06-row-actions", children: [add] }), + ); + + const setCard = createPanelCard(t("E06_Card_Settings"), true); + const crack = s.crack; + setCard.append( + settingRow( + "E06_Set_LateralPlanar", + numberInput( + view, + s.lateral_planar_deg, + "lateral_deg", + (v) => (s.lateral_planar_deg = v ?? NaN), + { + path: "settings.lateral_planar_deg", + }, + ), + ), + settingRow( + "E06_Set_LateralTopple", + numberInput( + view, + s.lateral_toppling_deg, + "lateral_deg", + (v) => (s.lateral_toppling_deg = v ?? NaN), + { path: "settings.lateral_toppling_deg" }, + ), + ), + settingRow( + "E06_Set_Gamma", + numberInput(view, s.gamma_r, "gamma_r", (v) => (s.gamma_r = v), { + optional: true, + path: "settings.gamma_r", + }), + ), + settingRow( + "E06_Set_WaterWet", + numberInput(view, s.water_fill_wet, "water_fill_wet", (v) => (s.water_fill_wet = v ?? NaN), { + step: "0.05", + path: "settings.water_fill_wet", + }), + ), + settingRow("E06_Set_MinBlock", minBlockControl(view, s)), + settingRow( + "E06_Set_Crack", + select( + view, + crack.mode, + [ + ["none", "E06_Crack_None"], + ["critical", "E06_Crack_Critical"], + ["depth", "E06_Crack_Depth"], + ], + (v) => { + crack.mode = v; + crack.z = v === "depth" ? (crack.z ?? 1) : null; + }, + "settings.crack.mode", + ), + ), + ); + if (crack.mode === "depth") + setCard.append( + settingRow( + "E06_Set_CrackZ", + numberInput(view, crack.z, "crack_z", (v) => (crack.z = v), { path: "settings.crack.z" }), + ), + ); + setCard.append( + settingRow( + "E06_Set_BoltForce", + numberInput(view, s.bolt.force, "bolt_force", (v) => (s.bolt.force = v ?? NaN), { + path: "settings.bolt.force", + }), + ), + settingRow( + "E06_Set_BoltPlunge", + numberInput(view, s.bolt.plunge, "bolt_plunge", (v) => (s.bolt.plunge = v ?? NaN), { + path: "settings.bolt.plunge", + }), + ), + el("p", { className: "e06-hint", text: t("E06_SettingsHint") }), + el("p", { className: "e06-hint", text: t("E06_MinBlockHint") }), + ); + return [setsCard, setCard]; +} diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts new file mode 100644 index 000000000..f96156bd7 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -0,0 +1,397 @@ +/* ============================================================================= + * E06_StabRock_UI_Page.ts + * E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow). + * 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석] + * 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도) + * 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표 + * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset · + * [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET. + * ========================================================================== */ + +import "./E06_StabRock_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { + createButton, + el, + hideLoadingOverlay, + showConfirmDialog, + showLoadingOverlay, + showToast, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { + buildJointCards, + type JointsDoc, + type JointsView, + type Projection, + type RockDefaults, +} from "./E06_StabRock_UI_Joints"; +import { + buildBand, + buildDetail, + buildList, + entryId, + type RockResult, + type StationEntry, +} from "./E06_StabRock_UI_Result"; +import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet"; + +interface RockResponse { + joints: JointsDoc; + saved?: boolean; + defaults?: RockDefaults; + stations?: { chainage_m: number; label: string; ground_type: string }[]; + editable?: boolean; +} + +interface ResultResponse { + status: string; + stale: boolean; + result: RockResult | null; +} + +interface StationResponse { + entry: StationEntry; + stereonet: Record & { phi: number }; +} + +const cacheKey = (projectId: string): string => `e06_rock_cache:${projectId}`; + +async function api(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 T & { detail?: unknown }; + if (!response.ok) { + const detail = body.detail; + if (typeof detail === "string") throw new Error(detail); + const errors = (detail as { errors?: unknown[] } | undefined)?.errors; + throw new Error(errors ? `${t("E06_Invalid")} ${errors.length}` : `HTTP ${response.status}`); + } + return body; +} + +function toggle(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) { + const group = el("div", { className: "e06-toggle" }); + const buttons = options.map(([key, v]) => { + const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); + button.dataset.value = v; + button.classList.toggle("is-on", v === value); + button.addEventListener("click", () => { + buttons.forEach((b) => b.classList.toggle("is-on", b === button)); + onPick(v); + }); + return button; + }); + group.append(...buttons); + return group; +} + +export function renderE06StabRock(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const panel = el("div", { className: "ui-sidebar-panel e06-panel" }); + const main = el("div", { className: "e06-page" }); + mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") })); + + // ---- 좌 ---- + const cards = el("div", { className: "e06-cards" }); + const status = el("p", { className: "e06-status" }); + const saveButton = createButton({ + label: t("Common_Btn_Save"), + variant: "ghost", + onClick: () => void save(), + }); + const resetButton = createButton({ + label: t("Common_Btn_Reset"), + variant: "danger-ghost", + onClick: () => void reset(), + }); + const analyzeButton = createButton({ + label: t("E06_Analyze"), + variant: "filled", + onClick: () => void analyze(), + }); + saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true; + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(resetButton, saveButton, analyzeButton); + panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions); + attachCollapsible(cards); + + // ---- 가운데 · 우 ---- + const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") }); + staleBar.hidden = true; + const bandSlot = el("div"); + const title = el("strong", { className: "e06-title" }); + const netSlot = el("div", { className: "e06-net-slot" }); + const legendSlot = el("div"); + let projection: Projection = "equal_angle"; + let netMode: NetMode = "all"; + const toolbar = el("div", { className: "e06-toolbar" }); + const projectionToggle = el("div"); + toolbar.append( + title, + projectionToggle, + toggle( + [ + ["E06_View_All", "all"], + ["E06_Mode_planar", "planar"], + ["E06_Mode_wedge", "wedge"], + ["E06_Mode_toppling", "toppling"], + ], + netMode, + (v) => { + netMode = v; + paintNet(); + }, + ), + ); + const stage = el("div", { className: "e06-stage" }); + stage.append(netSlot, legendSlot); + const center = el("div", { className: "e06-center" }); + center.append(staleBar, bandSlot, toolbar, stage); + const listSlot = el("div", { className: "e06-list-slot" }); + const detailSlot = el("div"); + const sideBody = el("div", { className: "e06-side" }); + sideBody.append(listSlot, detailSlot); + const side = createSidePanels({ + prefKey: "e06-stab-rock-result", + panels: [{ id: "result", title: t("E06_Result_Title"), body: sideBody }], + defaultOpen: "result", + defaultWidth: 380, + follow: false, + }); + const row = el("div", { className: "ui-side-panels-row e06-row" }); + row.append(center, side.root); + main.append(row); + + // ---- 상태 ---- + let view: JointsView | null = null; + let dirty = false; + let result: RockResult | null = null; + let active: string | null = null; + let shown: StationResponse | null = null; + + const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length; + const paintStatus = (): void => { + const lines: string[] = []; + if (view && !view.editable) lines.push(t("E06_ReadOnly")); + if (dirty) lines.push(t("E06_Unsaved")); + const bad = invalidCount(); + if (bad) lines.push(`${t("E06_Invalid")} ${bad}`); + status.textContent = lines.join("\n"); + status.classList.toggle("is-error", bad > 0); + }; + + const renderCards = (): void => { + if (!view) return; + // 다시 그려도 접힘은 그대로 + const folded = [...cards.children].map((c) => c.classList.contains("is-collapsed")); + const built = buildJointCards(view); + built.forEach( + (c, i) => folded[i] !== undefined && c.classList.toggle("is-collapsed", folded[i]), + ); + cards.replaceChildren(...built); + paintStatus(); + }; + + const remember = (): void => { + if (!view) return; + dirty = true; + sessionStorage.setItem(cacheKey(projectId), JSON.stringify(view.doc)); + }; + + function paintProjection(): void { + projectionToggle.replaceChildren( + toggle( + [ + ["E06_Proj_Angle", "equal_angle"], + ["E06_Proj_Area", "equal_area"], + ], + projection, + (v) => { + projection = v; // 보기 고름 — 정본 아님(해석 지문에도 안 들어감) + paintNet(); + }, + ), + ); + } + + function paintNet(): void { + legendSlot.replaceChildren(netLegend(netMode)); + if (!shown) { + title.textContent = ""; + netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") })); + return; + } + const e = shown.entry; + title.textContent = `${e.label ?? e.key} · ${t(e.slope.side === "left" ? "E06_Left" : "E06_Right")} · φ ${shown.stereonet.phi}°`; + netSlot.replaceChildren( + drawStereonet(shown.stereonet[projection], netMode, { + planar: e.kinematic.planar, + toppling: e.kinematic.toppling, + wedge: e.kinematic.wedge.map((w) => w.ids.join("|")), + }), + ); + } + + function paintResult(): void { + const stations = result?.stations ?? []; + bandSlot.replaceChildren(buildBand(stations, active, (id) => void select(id))); + listSlot.replaceChildren( + stations.length + ? buildList(stations, active, (id) => void select(id)) + : el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }), + ); + detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span")); + paintNet(); + } + + async function select(id: string): Promise { + active = id; + const [key, sideName] = id.split("|"); + try { + shown = await api( + `/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`, + ); + } catch (error) { + shown = null; + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + paintResult(); + } + + async function loadResult(): Promise { + const body = await api(`/projects/${projectId}/stab/rock/result`); + result = body.result; + staleBar.hidden = !body.stale; + const stations = result?.stations ?? []; + const keep = stations.find((e) => entryId(e) === active); + const first = keep ?? stations.find((e) => e.verdict === "NG") ?? stations[0]; + if (first) await select(entryId(first)); + else { + active = null; + shown = null; + paintResult(); + } + } + + const adopt = (body: RockResponse, keepCache: boolean): void => { + if (!view) return; + const cached = keepCache ? sessionStorage.getItem(cacheKey(projectId)) : null; + if (!cached) sessionStorage.removeItem(cacheKey(projectId)); + view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints; + dirty = Boolean(cached); + saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable; + renderCards(); + }; + + async function putJoints(): Promise { + if (!view?.editable) return false; + if (invalidCount()) { + showToast(`${t("E06_Invalid")} ${invalidCount()}`, "error"); + return false; + } + const body = await api(`/projects/${projectId}/stab/rock`, { + method: "PUT", + body: JSON.stringify({ joints: view.doc }), + }); + adopt(body, false); + return true; + } + + async function save(): Promise { + showLoadingOverlay(); + try { + if (await putJoints()) { + showToast(t("E06_Saved"), "success"); + await loadResult(); + } + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function reset(): Promise { + if (!view?.editable) return; + const ok = await showConfirmDialog( + t("E06_ResetConfirm"), + t("Common_Btn_Reset"), + t("Common_Btn_Cancel"), + ); + if (!ok) return; + showLoadingOverlay(); + try { + const body = await api(`/projects/${projectId}/stab/rock/reset`, { + method: "POST", + }); + adopt(body, false); + showToast(t("E06_ResetDone"), "success"); + await loadResult(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + hideLoadingOverlay(); + } + } + + async function analyze(): Promise { + if (!view?.editable) return; + analyzeButton.disabled = true; + status.textContent = t("E06_Running"); + try { + if (dirty && !(await putJoints())) return; + const body = await api<{ status: string; summary?: RockResult["summary"] }>( + `/projects/${projectId}/stab/rock/analyze`, + { method: "POST" }, + ); + await loadResult(); + showToast(t(body.status === "fresh" ? "E06_Fresh" : "E06_Done"), "success"); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } finally { + analyzeButton.disabled = !view.editable; + paintStatus(); + } + } + + void (async () => { + try { + const body = await api(`/projects/${projectId}/stab/rock`); + const stations = body.stations ?? []; + projection = body.joints.settings.projection; + paintProjection(); + view = { + doc: body.joints, + defaults: body.defaults as RockDefaults, + editable: body.editable ?? false, + endM: stations.length ? stations[stations.length - 1].chainage_m : 0, + change: () => { + remember(); + paintStatus(); + }, + rebuild: () => { + remember(); + renderCards(); + }, + }; + adopt(body, true); + await loadResult(); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e06-empty", + text: error instanceof Error ? error.message : t("Common_Status_Error"), + }), + ); + } + })(); +} diff --git a/E06_StabRock/E06_StabRock_UI_Result.ts b/E06_StabRock/E06_StabRock_UI_Result.ts new file mode 100644 index 000000000..b0ce05257 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Result.ts @@ -0,0 +1,235 @@ +/* ============================================================================= + * E06_StabRock_UI_Result.ts + * E06 결과 — 위험 띠(누가 축 · 측점 판정 색 · 누르면 그 측점) · 우측 측점 목록(쪽 · 모드 수 · + * 최소 안전율 · 판정) · 고른 측점 사면 · 모드별 안전율 표. 값은 서버 저장본 그대로(계산 없음). + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +export type Verdict = "NG" | "check" | "OK" | "none"; + +export interface RockCase { + mode: "planar" | "wedge"; + ids: string[]; + condition: "dry" | "wet" | "seismic"; + F: number | null; + formed: boolean; + persistence_short: boolean; + small: boolean; + required: number | null; + ok: boolean | null; + sliding?: string | null; +} + +export interface StationEntry { + key: string; + chainage_m: number; + label: string | null; + ground_type: string; + slope: { side: string; dip_direction: number; dip: number; height_m: number; upper_dip: number }; + gamma_r: number; + joints: { + id: string; + name: string; + dip_direction: number; + dip: number; + phi: number; + c: number; + }[]; + kinematic: { + planar: string[]; + toppling: string[]; + wedge: { ids: [string, string]; trend: number; plunge: number }[]; + }; + cases: RockCase[]; + counts: { planar: number; wedge: number; toppling: number }; + min_F: number | null; + small_blocks: number; + verdict: Verdict; +} + +export interface RockResult { + fingerprint: string; + summary: { + slopes: number; + verdicts: Partial>; + modes: Record; + small_blocks: number; + }; + stations: StationEntry[]; +} + +const ORDER: Verdict[] = ["NG", "check", "OK", "none"]; +export const entryId = (e: StationEntry): string => `${e.key}|${e.slope.side}`; +const fmt = (v: number | null | undefined, d = 2): string => + v === null || v === undefined ? "–" : v.toFixed(d); + +function badge(verdict: Verdict): HTMLElement { + return el("span", { + className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`, + text: t(`E06_V_${verdict}` as LocaleKey), + }); +} + +/** 위험 띠 — 측점마다 가장 나쁜 판정 · 이웃 측점 가운데서 끊음 */ +export function buildBand( + stations: StationEntry[], + active: string | null, + onSelect: (id: string) => void, +): HTMLElement { + const box = el("div", { className: "e06-band", attrs: { "data-role": "band" } }); + const worst = new Map(); + for (const e of stations) { + const prev = worst.get(e.chainage_m); + if (!prev || ORDER.indexOf(e.verdict) < ORDER.indexOf(prev.verdict)) worst.set(e.chainage_m, e); + } + const list = [...worst.values()].sort((a, b) => a.chainage_m - b.chainage_m); + const legend = el("div", { className: "e06-band__legend" }); + legend.append(el("strong", { text: t("E06_Band") })); + for (const v of ORDER) { + const n = list.filter((e) => e.verdict === v).length; + if (n) legend.append(badge(v), el("span", { className: "e06-band__count", text: String(n) })); + } + const strip = el("div", { className: "e06-band__strip" }); + if (list.length) { + const lo = list[0].chainage_m; + const hi = Math.max(list[list.length - 1].chainage_m, lo + 1); + list.forEach((e, i) => { + const a = i ? (list[i - 1].chainage_m + e.chainage_m) / 2 : lo; + const b = i < list.length - 1 ? (e.chainage_m + list[i + 1].chainage_m) / 2 : hi; + const seg = el("button", { + className: `e06-band__seg e06-band__seg--${e.verdict.toLowerCase()}${ + active?.startsWith(`${e.key}|`) ? " is-active" : "" + }`, + attrs: { + type: "button", + "data-key": e.key, + title: `${e.label ?? e.key} · ${t(`E06_V_${e.verdict}` as LocaleKey)}`, + }, + }); + seg.style.left = `${((a - lo) / (hi - lo)) * 100}%`; + seg.style.width = `${Math.max(0.4, ((b - a) / (hi - lo)) * 100)}%`; + seg.addEventListener("click", () => onSelect(entryId(e))); + strip.append(seg); + }); + const ticks = el("div", { className: "e06-band__ticks" }); + ticks.append( + el("span", { text: `${lo.toFixed(0)} m` }), + el("span", { text: `${hi.toFixed(0)} m` }), + ); + box.append(legend, strip, ticks); + } else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") })); + return box; +} + +export function buildList( + stations: StationEntry[], + active: string | null, + onSelect: (id: string) => void, +): HTMLElement { + const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } }); + const head = el("tr"); + for (const key of [ + "E06_L_Station", + "E06_L_Side", + "E06_L_Modes", + "E06_L_MinF", + "E06_L_Verdict", + ] as LocaleKey[]) + head.append(el("th", { text: t(key) })); + table.append(el("thead", { children: [head] }), el("tbody")); + for (const e of stations) { + const id = entryId(e); + const tr = el("tr", { + className: id === active ? "is-active" : "", + attrs: { "data-id": id, "data-verdict": e.verdict }, + }); + tr.append( + el("td", { text: e.label ?? e.key }), + el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }), + el("td", { text: `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}` }), + el("td", { text: fmt(e.min_F) }), + el("td", { children: [badge(e.verdict)] }), + ); + tr.addEventListener("click", () => onSelect(id)); + table.tBodies[0].append(tr); + } + return table; +} + +export function buildDetail(e: StationEntry): HTMLElement { + const box = el("div", { className: "e06-detail", attrs: { "data-role": "detail" } }); + const s = e.slope; + box.append( + el("h4", { text: `${e.label ?? e.key} · ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }), + el("p", { + className: "e06-hint", + text: t("E06_SlopeLine") + .replace("{dd}", s.dip_direction.toFixed(0)) + .replace("{dip}", s.dip.toFixed(1)) + .replace("{h}", s.height_m.toFixed(2)) + .replace("{up}", s.upper_dip.toFixed(1)) + .replace("{g}", e.gamma_r.toFixed(1)), + }), + ); + const kin = e.kinematic; + const kinText = [ + `${t("E06_Mode_planar")} ${kin.planar.join(", ") || "–"}`, + `${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("·")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "–"}`, + `${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "–"}`, + ]; + box.append(el("p", { className: "e06-kin", text: kinText.join("\n") })); + if (!e.cases.length) { + box.append( + el("p", { + className: "e06-hint", + text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"), + }), + ); + return box; + } + const table = el("table", { className: "e06-cases" }); + const head = el("tr"); + for (const key of [ + "E06_C_Mode", + "E06_C_Joints", + "E06_C_Cond", + "E06_C_F", + "E06_C_Req", + "E06_L_Verdict", + ] as LocaleKey[]) + head.append(el("th", { text: t(key) })); + table.append(el("thead", { children: [head] }), el("tbody")); + for (const c of e.cases) { + const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK"; + const note = !c.formed + ? t("E06_NotFormed") + : c.small + ? t("E06_Small") + : c.persistence_short + ? t("E06_Persist") + : ""; + const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } }); + tr.append( + el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }), + el("td", { text: c.ids.join("·") }), + el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }), + el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }), + el("td", { text: fmt(c.required) }), + el("td", { children: [c.small ? el("span", { text: "–" }) : badge(verdict)] }), + ); + table.tBodies[0].append(tr); + } + box.append(table); + if (e.small_blocks) + box.append( + el("p", { + className: "e06-hint", + text: t("E06_SmallCount").replace("{n}", String(e.small_blocks)), + }), + ); + if (kin.toppling.length) + box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") })); + return box; +} diff --git a/E06_StabRock/E06_StabRock_UI_Stereonet.ts b/E06_StabRock/E06_StabRock_UI_Stereonet.ts new file mode 100644 index 000000000..73e2b9e21 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Stereonet.ts @@ -0,0 +1,193 @@ +/* ============================================================================= + * E06_StabRock_UI_Stereonet.ts + * E06 가운데 스테레오넷(SVG · 하반구) — 서버가 내려 준 투영 좌표(반지름 1 · x = 동 · y = 북)를 + * 그리기만 함(계산 = E06_StabRock_Stereonet.py). 보기 = 전체 · 평면 · 쐐기 · 전도. + * 평면 · 전도 = 극점 + 극점 마찰원(φ) · 쐐기 = 교선 + 교선 마찰원 · 위험 영역 = 모드 색 · + * 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; + +const SVG_NS = "http://www.w3.org/2000/svg"; +const SIZE = 440; +const R = 190; +const C = SIZE / 2; + +export type NetMode = "all" | "planar" | "wedge" | "toppling"; +type XY = [number, number]; + +export interface NetData { + projection: string; + slope: { pole: XY; great_circle: XY[] }; + planes: { id: string; pole: XY; great_circle: XY[] }[]; + intersections: { ids: [string, string]; point: XY }[]; + friction_pole_r: number; + friction_line_r: number; + daylight_envelope: XY[]; + zones: Record<"planar" | "wedge" | "toppling", XY[] | null>; +} + +export interface NetMarks { + planar: string[]; + toppling: string[]; + wedge: string[]; // "J1|J2" +} + +function svg( + tag: K, + attrs: Record = {}, + text?: string, +): SVGElementTagNameMap[K] { + const node = document.createElementNS(SVG_NS, tag); + for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v)); + if (text !== undefined) node.textContent = text; + return node; +} + +const sx = (p: XY): number => C + R * p[0]; +const sy = (p: XY): number => C - R * p[1]; +const path = (pts: XY[], close = false): string => + pts.map((p, i) => `${i ? "L" : "M"}${sx(p).toFixed(1)},${sy(p).toFixed(1)}`).join("") + + (close ? "Z" : ""); + +/** 절리 색 번호(0~5) — 표 · 그림 같은 차례 */ +export const jointTone = (index: number): number => index % 6; + +export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVGSVGElement { + const root = svg("svg", { + viewBox: `0 0 ${SIZE} ${SIZE}`, + class: "e06-net", + "data-role": "stereonet", + "data-projection": net.projection, + "data-mode": mode, + }); + const clip = svg("clipPath", { id: "e06-net-clip" }); + clip.append(svg("circle", { cx: C, cy: C, r: R })); + const defs = svg("defs"); + defs.append(clip); + root.append(defs, svg("circle", { cx: C, cy: C, r: R, class: "e06-net__rim" })); + const body = svg("g", { "clip-path": "url(#e06-net-clip)" }); + root.append(body); + + const show = (m: Exclude): boolean => mode === "all" || mode === m; + for (const m of ["planar", "wedge", "toppling"] as const) { + const zone = net.zones[m]; + if (zone && show(m)) + body.append( + svg("path", { + d: path(zone, true), + class: `e06-net__zone e06-net__zone--${m}`, + "data-zone": m, + }), + ); + } + // 마찰원 + if (show("planar") || show("toppling")) + body.append( + svg("circle", { + cx: C, + cy: C, + r: R * net.friction_pole_r, + class: "e06-net__friction", + "data-role": "friction-pole", + }), + ); + if (show("wedge")) + body.append( + svg("circle", { + cx: C, + cy: C, + r: R * net.friction_line_r, + class: "e06-net__friction e06-net__friction--line", + "data-role": "friction-line", + }), + ); + if (show("planar")) + body.append( + svg("path", { + d: path(net.daylight_envelope), + class: "e06-net__envelope", + "data-role": "daylight", + }), + ); + // 대원 + net.planes.forEach((p, i) => + body.append( + svg("path", { + d: path(p.great_circle), + class: `e06-net__gc e06-tone-${jointTone(i)}`, + "data-id": p.id, + }), + ), + ); + body.append( + svg("path", { d: path(net.slope.great_circle), class: "e06-net__slope", "data-role": "slope" }), + ); + + // 극점(평면 · 전도 보기) · 교선(쐐기 보기) + if (mode !== "wedge") + net.planes.forEach((p, i) => { + const risky = marks.planar.includes(p.id) || marks.toppling.includes(p.id); + const g = svg("g", { + class: `e06-net__pole e06-tone-${jointTone(i)}${risky ? " is-risk" : ""}`, + "data-pole": p.id, + }); + g.append(svg("circle", { cx: sx(p.pole), cy: sy(p.pole), r: 5 })); + g.append(svg("text", { x: sx(p.pole) + 7, y: sy(p.pole) - 6 }, p.id)); + root.append(g); + }); + if (mode === "all" || mode === "wedge") + for (const line of net.intersections) { + const key = line.ids.join("|"); + const risky = marks.wedge.includes(key); + const [x, y] = [sx(line.point), sy(line.point)]; + const g = svg("g", { class: `e06-net__line${risky ? " is-risk" : ""}`, "data-line": key }); + g.append(svg("path", { d: `M${x},${y - 6}L${x + 5.5},${y + 4}L${x - 5.5},${y + 4}Z` })); + g.append(svg("text", { x: x + 7, y: y + 12 }, line.ids.join("·"))); + root.append(g); + } + // 사면 극점 · 방위 + root.append( + svg("rect", { + x: sx(net.slope.pole) - 4, + y: sy(net.slope.pole) - 4, + width: 8, + height: 8, + class: "e06-net__slope-pole", + }), + ); + for (const [label, x, y] of [ + ["N", C, C - R - 8], + ["E", C + R + 10, C + 4], + ["S", C, C + R + 18], + ["W", C - R - 10, C + 4], + ] as const) + root.append(svg("text", { x, y, class: "e06-net__dir", "text-anchor": "middle" }, label)); + root.append(svg("line", { x1: C - 6, y1: C, x2: C + 6, y2: C, class: "e06-net__tick" })); + root.append(svg("line", { x1: C, y1: C - 6, x2: C, y2: C + 6, class: "e06-net__tick" })); + return root; +} + +export function netLegend(mode: NetMode): HTMLElement { + const box = el("div", { className: "e06-legend" }); + const items: [string, LocaleKey][] = [ + ["slope", "E06_Lg_Slope"], + ["friction", "E06_Lg_Friction"], + ]; + if (mode === "all" || mode === "planar") + items.push(["planar", "E06_Lg_Planar"], ["daylight", "E06_Lg_Daylight"]); + if (mode === "all" || mode === "wedge") + items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]); + if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]); + if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]); + for (const [cls, key] of items) { + const row = el("span", { className: "e06-legend__row" }); + row.append( + el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }), + el("span", { text: t(key) }), + ); + box.append(row); + } + return box; +} diff --git a/E06_StabRock/E06_StabRock_UI_Style.css b/E06_StabRock/E06_StabRock_UI_Style.css new file mode 100644 index 000000000..eb347f792 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Style.css @@ -0,0 +1,491 @@ +/* ============================================================================= + * E06_StabRock_UI_Style.css + * E06 암반 해석 — 좌측 절리 표 · 설정 / 가운데 위험 띠 · 스테레오넷 / 우측 측점 결과 + * 색은 테마 토큰(밝음 · 어두움 같이) · 판정 · 모드 · 절리 색만 고정색(E03 판정색과 같음) + * ========================================================================== */ + +.e06-page { + --e06-ng: #d03b3b; + --e06-ok: #0ca30c; + --e06-check: #e0a106; + --e06-none: #9a9a95; + --e06-planar: #d03b3b; + --e06-wedge: #f1963b; + --e06-toppling: #9b6dde; + height: 100dvh; + box-sizing: border-box; +} + +.e06-row { + height: 100%; +} + +.e06-center { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; + padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12); + box-sizing: border-box; + overflow: auto; +} + +.e06-empty, +.e06-hint, +.e06-status, +.e06-kin { + margin: 0; + white-space: pre-line; + color: var(--color-text-muted); + font-size: 12px; +} + +.e06-empty { + padding: var(--spacing-16); +} + +.e06-status.is-error { + color: var(--color-danger); +} + +.e06-kin { + color: var(--color-text); +} + +.e06-panel .ui-sidebar-actions { + flex-wrap: wrap; +} + +.e06-stale { + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + color: var(--color-text); +} + +.e06-stale[hidden] { + display: none; +} + +/* ---- 좌측 표 · 설정 ---- */ +.e06-table-wrap { + overflow-x: auto; +} + +.e06-table { + border-collapse: collapse; + font-size: 12px; +} + +.e06-table th { + padding: 2px 3px; + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + white-space: nowrap; +} + +.e06-table td { + padding: 1px 2px; +} + +.e06-input { + width: 4.2em; + padding: 2px 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 12px; + box-sizing: border-box; +} + +.e06-input--name { + width: 3.6em; +} + +select.e06-input { + width: auto; +} + +.e06-input.is-invalid { + border-color: var(--color-danger); + background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface)); +} + +.e06-row-actions { + display: flex; + justify-content: flex-end; + margin-top: var(--spacing-4); +} + +.e06-setting { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + margin: 2px 0; + font-size: 12px; + color: var(--color-text); +} + +.e06-setting .e06-input { + width: 5.5em; +} + +.e06-setting__ctl { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + +/* ---- 가운데 ---- */ +.e06-toolbar { + display: flex; + align-items: center; + gap: var(--spacing-12); + flex-wrap: wrap; +} + +.e06-title { + color: var(--color-text); +} + +.e06-toggle { + display: inline-flex; + gap: var(--spacing-4); +} + +.e06-toggle .ui-btn.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); +} + +.e06-stage { + display: flex; + gap: var(--spacing-12); + align-items: flex-start; + flex-wrap: wrap; + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); +} + +.e06-net-slot { + flex: 1 1 360px; + max-width: 560px; +} + +.e06-net { + display: block; + width: 100%; + height: auto; + color: var(--color-text); + font-size: 11px; +} + +.e06-net__rim { + fill: var(--color-surface); + stroke: var(--color-text); + stroke-width: 1.4; +} + +.e06-net__zone { + stroke: none; + opacity: 0.28; +} + +.e06-net__zone--planar { + fill: var(--e06-planar); +} + +.e06-net__zone--wedge { + fill: var(--e06-wedge); +} + +.e06-net__zone--toppling { + fill: var(--e06-toppling); +} + +.e06-net__friction { + fill: none; + stroke: var(--color-text-secondary); + stroke-width: 1; + stroke-dasharray: 5 3; +} + +.e06-net__friction--line { + stroke-dasharray: 2 3; +} + +.e06-net__envelope { + fill: none; + stroke: var(--e06-planar); + stroke-width: 1; + stroke-dasharray: 6 3; +} + +.e06-net__gc { + fill: none; + stroke: currentColor; + stroke-width: 1.2; +} + +.e06-net__slope { + fill: none; + stroke: var(--color-text); + stroke-width: 2.6; +} + +.e06-net__slope-pole { + fill: var(--color-text); +} + +.e06-net__pole circle { + fill: currentColor; + stroke: var(--color-surface); + stroke-width: 1; +} + +.e06-net__pole.is-risk circle, +.e06-net__line.is-risk path { + stroke: var(--e06-ng); + stroke-width: 2.4; +} + +.e06-net__pole text, +.e06-net__line text, +.e06-net__dir { + fill: var(--color-text); +} + +.e06-net__line path { + fill: var(--e06-wedge); + stroke: var(--color-surface); +} + +.e06-net__tick { + stroke: var(--color-text-secondary); +} + +.e06-tone-0 { + color: #2f6fd6; +} +.e06-tone-1 { + color: #1f9d6a; +} +.e06-tone-2 { + color: #c2410c; +} +.e06-tone-3 { + color: #7c4dcc; +} +.e06-tone-4 { + color: #b8860b; +} +.e06-tone-5 { + color: #0e7490; +} + +.e06-legend { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--color-text); +} + +.e06-legend__row { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.e06-legend__mark { + display: inline-block; + width: 16px; + height: 10px; + border-radius: 2px; +} + +.e06-legend__mark--slope { + height: 3px; + background: var(--color-text); +} +.e06-legend__mark--friction { + height: 0; + border-top: 1px dashed var(--color-text-secondary); +} +.e06-legend__mark--daylight { + height: 0; + border-top: 1px dashed var(--e06-planar); +} +.e06-legend__mark--planar { + background: color-mix(in srgb, var(--e06-planar) 35%, transparent); +} +.e06-legend__mark--wedge { + background: color-mix(in srgb, var(--e06-wedge) 35%, transparent); +} +.e06-legend__mark--toppling { + background: color-mix(in srgb, var(--e06-toppling) 35%, transparent); +} +.e06-legend__mark--line { + width: 10px; + background: var(--e06-wedge); + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} +.e06-legend__mark--pole { + width: 10px; + border-radius: 50%; + background: #2f6fd6; +} + +/* ---- 위험 띠 ---- */ +.e06-band { + display: flex; + flex-direction: column; + gap: 4px; +} + +.e06-band__legend { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + font-size: 12px; + color: var(--color-text); +} + +.e06-band__count { + margin-right: 6px; + color: var(--color-text-secondary); +} + +.e06-band__strip { + position: relative; + height: 18px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + overflow: hidden; +} + +.e06-band__seg { + position: absolute; + top: 0; + bottom: 0; + padding: 0; + border: none; + cursor: pointer; +} + +.e06-band__seg--ng { + background: var(--e06-ng); +} +.e06-band__seg--ok { + background: var(--e06-ok); +} +.e06-band__seg--check { + background: var(--e06-check); +} +.e06-band__seg--none { + background: var(--e06-none); +} + +.e06-band__seg.is-active { + outline: 2px solid var(--color-text); + outline-offset: -2px; + z-index: 1; +} + +.e06-band__ticks { + display: flex; + justify-content: space-between; + font-size: 11px; + color: var(--color-text-secondary); +} + +/* ---- 우측 ---- */ +.e06-side { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} + +.e06-list-slot { + max-height: 46vh; + overflow: auto; +} + +.e06-list, +.e06-cases { + width: 100%; + border-collapse: collapse; + font-size: 12px; + color: var(--color-text); +} + +.e06-list th, +.e06-cases th { + position: sticky; + top: 0; + padding: 3px 4px; + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + text-align: left; +} + +.e06-list td, +.e06-cases td { + padding: 3px 4px; + border-top: 1px solid var(--color-border); +} + +.e06-list tbody tr { + cursor: pointer; +} + +.e06-list tbody tr:hover { + background: var(--color-surface-raised); +} + +.e06-list tr.is-active { + background: color-mix(in srgb, var(--color-primary) 16%, transparent); +} + +.e06-detail h4 { + margin: 0 0 4px; + color: var(--color-text); +} + +.e06-verdict { + display: inline-flex; + align-items: center; + gap: 4px; + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.e06-verdict::before { + content: ""; + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--e06-none); +} + +.e06-verdict--ng::before { + background: var(--e06-ng); + border-radius: 2px; + transform: rotate(45deg); +} +.e06-verdict--ok::before { + background: var(--e06-ok); +} +.e06-verdict--check::before { + background: var(--e06-check); + border-radius: 0; + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} diff --git a/E06_StabRock/draft/E06_StabRock_Route.ts b/E06_StabRock/draft/E06_StabRock_Route.ts new file mode 100644 index 000000000..627bed897 --- /dev/null +++ b/E06_StabRock/draft/E06_StabRock_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E06_StabRock_Route.ts + * 길 선언(PLAN 536-1) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E06 암반 평사투영 페이지 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E06_STAB_ROCK, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E06_StabRock_UI_Page")).renderE06StabRock, +}; diff --git a/E07_StabArea/E07_StabArea_Engine.py b/E07_StabArea/E07_StabArea_Engine.py new file mode 100644 index 000000000..1ff42172b --- /dev/null +++ b/E07_StabArea/E07_StabArea_Engine.py @@ -0,0 +1,328 @@ +"""E07 광역 자연사면 위험 — 무한사면 안전율 · SINMAP 안정 지수 (요구 345 · 351 · PLAN 536-2). + +순수 계산(storage 모름) — D 예정노선 재해 안전성도 같은 엔진을 씀(한 벌). +근거 = 지식DB `original/예정노선/02` §1.1 · §2 ① · `original/안정성평가/04`(붕적토 범위). + + 지형 DEM → 고른 격자(cell_m) · 경사 β(Horn 3×3) · D8 흐름(웅덩이 채움 · 수신 셀 = B04 배수유역 + 함수 재사용) · 단위 집수면적 a = 상류 셀 수 × cell²/cell(등고선 폭 = cell) + 포화비 m 시나리오 둘 + sinmap(기본 · SINMAP 표준) = 습윤지수 W = min(x·a / sinβ, 1) · x = R/T ∈ [1/(T/R)_hi, 1/(T/R)_lo] + saturated(최악 · 강우 극한) = 지표 포화 m = 1 + 안전율 Fs = (c + c_r + [γt z(1−m) + (γsat−γw) m z] cos²β tanφ) + / ([γt z(1−m) + γsat m z] sinβ cosβ) + 안정 지수 SI(SINMAP · Pack 1998) — c ∈ [c, c_hi] · tanφ ∈ [tanφ, tanφ_hi] · x ∈ [x_lo, x_hi] 고른 분포 + 하한 Fs_min = (c, tanφ, x_hi 가장 젖음) ≥ 1 → SI = Fs_min · 상한 Fs_max = (c_hi, tanφ_hi, x_lo) < 1 + → SI = 0 · 그 사이 → SI = P(Fs > 1)(c · tanφ 닫힌 꼴 · x 는 중점 적분 X_STEPS 마디) + 등급 1 안정(SI ≥ 1.5) · 2 보통 안정(≥ 1.25) · 3 준안정(≥ 1.0) · 4 위험 하한(≥ 0.5) + · 5 위험 상한(> 0) · 6 불안정(SI = 0) · 0 = 자료 없음 + +격자 꼴 = 행 0 이 남쪽(y0) · 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) — E04 래스터와 같음. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any + +import numpy as np +from scipy.interpolate import RegularGridInterpolator +from scipy.spatial import cKDTree + +from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import ( + compute_receivers, + condition_surface, +) + +GAMMA_W = 9.81 # kN/m³ +SI_EDGES = (1.5, 1.25, 1.0, 0.5) # 등급 1~4 아래 경계 · 5 = (0, 0.5) · 6 = 0 +CLASS_KEYS = ("", "stable", "moderate", "quasi", "lower", "upper", "defended") +RISK_CLASS = 4 # 이 등급부터 위험(SI < 1) +LEVEL_KEYS = ("low", "mid", "high") # 측점 등급 — 영향 구역 안 위험 셀 비율 기준 +LEVEL_LO_PCT = 5.0 # 낮음 < 이 % ≤ 보통 +LEVEL_HI_PCT = 20.0 # 보통 < 이 % ≤ 높음(이 % 이상 = 높음) +FLAT_SLOPE_RAD = 1e-4 # 이보다 평평하면 활동 없음(Fs = ∞) +X_STEPS = 20 # SI 확률의 x = R/T 중점 적분 마디 + + +@dataclass(frozen=True) +class AreaParams: + """한 번 해석 조건 — 단위 kN/m³ · kPa · ° · m · mm/h · m/s.""" + + gamma_t: float + gamma_sat: float + c: float # 점착력 아래(결정 Fs · SI 범위 아래) + c_hi: float # SI 범위 위 + phi: float + phi_hi: float + c_root: float = 0.0 # 뿌리 점착(나지 = 0) + soil_depth_m: float = 1.5 + scenario: str = "sinmap" # sinmap = 습윤지수(T/R 범위) · saturated = 지표 포화 m = 1(최악) + tr_lo: float = 2000.0 # T/R 아래(m · 젖은 쪽) + tr_hi: float = 3000.0 # T/R 위(m · 마른 쪽) + + +# --------------------------------------------------------------------------- +# 지형 +# --------------------------------------------------------------------------- + + +def resample_dem(dem: dict[str, Any], cell_m: float) -> tuple[np.ndarray, float, float]: + """DTM npz(x · y 오름 축 · z (ny, nx) · valid_mask) → 고른 격자 (z, x0, y0) · 밖 = NaN.""" + x = np.asarray(dem["x"], float) + y = np.asarray(dem["y"], float) + z = np.where(np.asarray(dem["valid_mask"], bool), np.asarray(dem["z"], float), np.nan) + nx = int(math.floor((x[-1] - x[0]) / cell_m)) + 1 + ny = int(math.floor((y[-1] - y[0]) / cell_m)) + 1 + gx = x[0] + np.arange(nx) * cell_m + gy = y[0] + np.arange(ny) * cell_m + yy, xx = np.meshgrid(gy, gx, indexing="ij") + f = RegularGridInterpolator((y, x), z, bounds_error=False, fill_value=np.nan) + return f(np.column_stack([yy.ravel(), xx.ravel()])).reshape(ny, nx), float(x[0]), float(y[0]) + + +def slope_rad(z: np.ndarray, cell_m: float) -> np.ndarray: + """Horn 3×3 경사(rad) — 이웃에 NaN 이 있거나 격자 끝이면 NaN.""" + p = np.pad(z, 1, constant_values=np.nan) + + def at(dy: int, dx: int) -> np.ndarray: + return p[1 + dy : 1 + dy + z.shape[0], 1 + dx : 1 + dx + z.shape[1]] + + gx = ((at(-1, 1) + 2 * at(0, 1) + at(1, 1)) - (at(-1, -1) + 2 * at(0, -1) + at(1, -1))) / ( + 8 * cell_m + ) + gy = ((at(1, -1) + 2 * at(1, 0) + at(1, 1)) - (at(-1, -1) + 2 * at(-1, 0) + at(-1, 1))) / ( + 8 * cell_m + ) + return np.arctan(np.hypot(gx, gy)) + + +def catchment_area(z: np.ndarray, cell_m: float) -> np.ndarray: + """D8 단위 집수면적 a(m) = 자기 포함 상류 셀 수 × cell. 밖 = NaN. + + ponytail: 지형 격자 끝 밖 상류는 모름(복사 DTM = 노선 띠) — 띠 끝 셀은 a 가 작게 나옴 · + 넓은 DEM(D 예정노선 등)이면 그대로 맞음. + """ + filled, valid = condition_surface(z.astype(np.float32)) + spec = SimpleNamespace(n_rows=z.shape[0], n_cols=z.shape[1], cell_m=cell_m) + receiver, _ = compute_receivers(spec, filled, valid) + flat = filled.ravel() + order = np.flatnonzero(valid.ravel()) + order = order[np.argsort(-flat[order], kind="stable")].tolist() # 높은 데서 낮은 데로 + acc = valid.ravel().astype(float).tolist() + recv = receiver.tolist() + for i in order: + r = recv[i] + if r != i: + acc[r] += acc[i] + out = np.asarray(acc).reshape(z.shape) * cell_m + return np.where(valid, out, np.nan) + + +# --------------------------------------------------------------------------- +# 무한사면 · SINMAP +# --------------------------------------------------------------------------- + + +def wetness(beta: np.ndarray, a: np.ndarray, x: float) -> np.ndarray: + """습윤지수 W = min(x·a / sinβ, 1) · x = R/T(1/m).""" + with np.errstate(divide="ignore", invalid="ignore"): + m = x * a / np.sin(beta) + return np.clip(np.nan_to_num(m, nan=1.0, posinf=1.0), 0.0, 1.0) + + +def x_range(p: AreaParams) -> tuple[float, float]: + """x = R/T 범위(마른 쪽, 젖은 쪽) — T/R 위 · 아래의 역수.""" + return 1.0 / max(p.tr_hi, p.tr_lo), 1.0 / p.tr_lo + + +def wet_m(beta: np.ndarray, a: np.ndarray, p: AreaParams, x: float | None = None) -> np.ndarray: + """시나리오 포화비 — saturated = 1 · sinmap = W(x · 없으면 가장 젖은 x_hi).""" + if p.scenario == "saturated": + return np.ones_like(beta) + return wetness(beta, a, x_range(p)[1] if x is None else x) + + +def _terms(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> tuple[np.ndarray, np.ndarray]: + """Fs = (c + c_r)·A + tanφ·B 의 A · B(평평한 셀 = ∞).""" + z = p.soil_depth_m + cos, sin = np.cos(beta), np.sin(beta) + normal = (p.gamma_t * z * (1 - m) + (p.gamma_sat - GAMMA_W) * m * z) * cos**2 + shear = (p.gamma_t * z * (1 - m) + p.gamma_sat * m * z) * sin * cos + flat = beta < FLAT_SLOPE_RAD + with np.errstate(divide="ignore", invalid="ignore"): + a = np.where(flat, np.inf, 1.0 / shear) + b = np.where(flat, np.inf, normal / shear) + return a, b + + +def infinite_slope_fs(beta: np.ndarray, m: np.ndarray, p: AreaParams) -> np.ndarray: + """결정 안전율(c · φ = 범위 아래 = 보수) — 지식DB 02 §1.1 식.""" + a, b = _terms(beta, m, p) + with np.errstate(invalid="ignore"): + return np.where( + np.isinf(a), np.inf, (p.c + p.c_root) * a + math.tan(math.radians(p.phi)) * b + ) + + +def _cdf_sum_uniform( + s: np.ndarray, a1: np.ndarray, a2: np.ndarray, b1: np.ndarray, b2: np.ndarray +) -> np.ndarray: + """P(X + Y ≤ s) · X ~ U[a1, a2] · Y ~ U[b1, b2] 독립 — 폭 0 이면 그 변수는 상수.""" + wa, wb = a2 - a1, b2 - b1 + eps = 1e-12 + + def g(u: np.ndarray) -> np.ndarray: + return np.maximum(u, 0.0) ** 2 / 2 + + with np.errstate(divide="ignore", invalid="ignore"): + both = (g(s - a1 - b1) - g(s - a2 - b1) - g(s - a1 - b2) + g(s - a2 - b2)) / (wa * wb) + only_a = (s - a1 - b1) / wa + only_b = (s - a1 - b1) / wb + step = (a1 + b1 <= s).astype(float) + out = np.where( + (wa > eps) & (wb > eps), both, np.where(wa > eps, only_a, np.where(wb > eps, only_b, step)) + ) + return np.clip(np.nan_to_num(out, nan=0.0), 0.0, 1.0) + + +def stability_index(beta: np.ndarray, area: np.ndarray, p: AreaParams) -> np.ndarray: + """SINMAP 안정 지수 — 하한 Fs_min ≥ 1 → Fs_min · 상한 Fs_max < 1 → 0 · 그 사이 → P(Fs > 1).""" + t1, t2 = math.tan(math.radians(p.phi)), math.tan(math.radians(max(p.phi_hi, p.phi))) + c1, c2 = p.c + p.c_root, max(p.c_hi, p.c) + p.c_root + x_lo, x_hi = x_range(p) + + def ab(m: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + a, b = _terms(beta, m, p) + return np.where(np.isinf(a), 0.0, a), np.where(np.isinf(b), 0.0, b) + + a, b = ab(wet_m(beta, area, p, x_hi)) + lo = c1 * a + t1 * b + a, b = ab(wet_m(beta, area, p, x_lo)) + hi = c2 * a + t2 * b + xs = ( + [x_hi] + if p.scenario == "saturated" + else x_lo + (x_hi - x_lo) * (np.arange(X_STEPS) + 0.5) / X_STEPS + ) + prob = np.zeros_like(beta, dtype=float) + one = np.ones_like(prob) + for x in xs: + a, b = ab(wet_m(beta, area, p, x)) + prob += 1.0 - _cdf_sum_uniform(one, c1 * a, c2 * a, t1 * b, t2 * b) + prob /= len(xs) + si = np.where(lo >= 1.0, lo, np.where(hi < 1.0, 0.0, prob)) + return np.where(beta < FLAT_SLOPE_RAD, np.inf, si) + + +def classify(si: np.ndarray) -> np.ndarray: + """SI → 등급 1~6(0 = 자료 없음 · NaN).""" + cls = np.full(si.shape, 6, dtype=np.uint8) + cls[si > 0] = 5 + for k, edge in enumerate(reversed(SI_EDGES)): # 0.5 → 4 · 1.0 → 3 · 1.25 → 2 · 1.5 → 1 + cls[si >= edge] = 4 - k + cls[np.isnan(si)] = 0 + return cls + + +def analyze_grid(z: np.ndarray, cell_m: float, p: AreaParams) -> dict[str, np.ndarray]: + """고른 격자 지형 → {slope(rad) · a · m · fs · si · cls} · 밖 = NaN / 0.""" + beta = slope_rad(z, cell_m) + a = catchment_area(z, cell_m) if p.scenario != "saturated" else np.full(z.shape, np.nan) + m = wet_m(beta, a, p) # 가장 젖은 쪽(Fs = 하한과 같은 조건) + fs = infinite_slope_fs(beta, m, p) + si = stability_index(beta, a, p) + nodata = np.isnan(beta) + fs[nodata] = np.nan + si[nodata] = np.nan + m = np.where(nodata, np.nan, m) + return {"slope": beta, "a": a, "m": m, "fs": fs, "si": si, "cls": classify(si)} + + +# --------------------------------------------------------------------------- +# 노선 띠 · 측점 +# --------------------------------------------------------------------------- + + +def dense_line(line: np.ndarray, step_m: float = 0.5) -> np.ndarray: + """중심선 [x, y, 누가] 를 step 간격으로 촘촘히(꺾인 점 사이 셀도 바른 누가).""" + out = [line[:1]] + for a, b in zip(line[:-1], line[1:]): + n = max(1, int(math.ceil(math.hypot(*(b[:2] - a[:2])) / step_m))) + out.append(a + (b - a) * np.linspace(0, 1, n + 1)[1:, None]) + return np.vstack(out) + + +def band_cells( + shape: tuple[int, int], x0: float, y0: float, cell_m: float, line: np.ndarray, half_m: float +) -> tuple[np.ndarray, np.ndarray]: + """노선 띠(중심선 ± half_m) 셀 → (띠 안, 셀의 가장 가까운 중심선 누가).""" + dense = dense_line(np.asarray(line, float)) + yy, xx = np.meshgrid( + y0 + np.arange(shape[0]) * cell_m, x0 + np.arange(shape[1]) * cell_m, indexing="ij" + ) + dist, at = cKDTree(dense[:, :2]).query(np.column_stack([xx.ravel(), yy.ravel()])) + return (dist <= half_m).reshape(shape), dense[at, 2].reshape(shape) + + +def station_level( + risk_cells: int, cells: int, lo_pct: float = LEVEL_LO_PCT, hi_pct: float = LEVEL_HI_PCT +) -> str: + """측점 등급 — 위험(SI < 1) 셀 비율 < lo_pct 낮음 · lo_pct 이상 hi_pct 미만 보통 · + hi_pct 이상 높음. + (정수 곱 비교 — 문턱 경계에서 소수 오차 없음)""" + if risk_cells * 100 >= hi_pct * cells: + return "high" + return "mid" if risk_cells * 100 >= lo_pct * cells else "low" + + +def station_table( + chainages: list[float], + cell_chain: np.ndarray, + inside: np.ndarray, + out: dict[str, np.ndarray], + cell_m: float, + lo_pct: float = LEVEL_LO_PCT, + hi_pct: float = LEVEL_HI_PCT, +) -> list[dict[str, Any]]: + """측점마다 맡은 띠 셀(이웃 측점과 중간 누가로 나눔 = 앞뒤 ½ 간격 × 띠 폭) — + 최소 Fs · SI · 가장 나쁜 등급 · 위험 면적 · 위험 셀 비율 · 측점 등급.""" + chs = np.asarray(sorted(chainages), float) + if not len(chs): + return [] + mids = (chs[:-1] + chs[1:]) / 2 + owner = np.searchsorted(mids, cell_chain) + have = inside & (out["cls"] > 0) + rows = [] + for k, ch in enumerate(chs): + sel = have & (owner == k) + n = int(sel.sum()) + if not n: + rows.append({"chainage_m": float(ch), "cells": 0}) + continue + cls = out["cls"][sel] + fs = out["fs"][sel] + si = out["si"][sel] + risk = int((cls >= RISK_CLASS).sum()) + rows.append( + { + "chainage_m": float(ch), + "cells": n, + "fs_min": round(float(np.min(fs)), 3), + "si_min": round(float(np.min(si)), 3), + "worst_class": int(cls.max()), + "risk_area_m2": round(risk * cell_m**2, 1), + "risk_ratio": round(risk / n, 4), + "level": station_level(risk, n, lo_pct, hi_pct), + "slope_max_deg": round(float(np.degrees(np.nanmax(out["slope"][sel]))), 1), + } + ) + return rows + + +def class_areas(cls: np.ndarray, inside: np.ndarray, cell_m: float) -> dict[str, float]: + """띠 안 등급별 면적 m².""" + return { + CLASS_KEYS[k]: round(float(((cls == k) & inside).sum()) * cell_m**2, 1) for k in range(1, 7) + } diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py new file mode 100644 index 000000000..159cf7d51 --- /dev/null +++ b/E07_StabArea/E07_StabArea_Router.py @@ -0,0 +1,350 @@ +"""E07 광역 자연사면 위험 지도 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2). + +- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문이 저장본과 같으면 + `cached` · force = 다시 +- `GET …/stab/area/progress` — 진행률 +- `GET …/stab/area` — 기본값(E02 붕적토) · 물성 목록 · 노선 · 측점 · 저장본(격자 · 측점표 · 요약) + +계산 자리 = ③ 서버 단독(엔진 `E07_StabArea_Engine` · 순수). 지형 = 복사본 원지형 DTM +(노선면 합성 안 함 — 노선 위 · 아래 자연사면). +파생 저장본 = `E07_StabArea/_derived/area.json`(지문이 같으면 안 셈). +권한 = main.py `protected_with_company`(지정받은 회사 = GET 만). + +격자 꼴(화면 `logic/E07_StabArea_Raster.ts` 와 같은 열쇠) — grid = {x0, y0, dx, nx, ny} +(행 0 = 남쪽) · fs · si = uint16 ×1000(65535 = 자료 없음 · 65.534 위로 자름) · +cls = uint8 등급(0 없음 · 1~6) · slope = uint16 ×10 ° · m = uint8 ×100(255 없음) +— 모두 줄 우선 · 작은 끝 base64 · 띠 밖 셀 = 자료 없음. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import json +import logging +import time +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any, Literal +from uuid import UUID + +import aiomysql +import numpy as np +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field, model_validator + +from common_util.common_util_auth import require_company +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 E01_StabRegister import E01_StabRegister_Source as source +from E02_StabGround import E02_StabGround_Model as ground_model +from E07_StabArea import E07_StabArea_Engine as engine + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/projects", tags=["E07 Stab Area"]) + +ENGINE_VERSION = "e07-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +RESULT = Path("E07_StabArea") / "_derived" / "area.json" +DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토) +RANGE_C_KPA, RANGE_PHI_DEG = ( + 5.0, + 3.0, +) # SI 범위 위 = 아래 + 이 폭(04 붕적토 범위 c 0~10 · φ 25~30 폭의 반) + +_JOBS: dict[str, dict[str, Any]] = {} +_TASKS: set[asyncio.Task] = set() +_IDLE = {"running": False, "percent": 0, "message": None, "error": None} + + +class AreaRequest(BaseModel): + band_m: float = Field(100.0, ge=10.0, le=500.0) # 노선 띠 = 중심선 ± 이 폭 + cell_m: float = Field(2.0, ge=1.0, le=10.0) + material_id: str | None = None # 이름표만(값은 아래 칸) + gamma_t: float = Field(..., gt=0.0, le=30.0) + gamma_sat: float = Field(..., gt=engine.GAMMA_W, le=30.0) + c: float = Field(..., ge=0.0, le=500.0) + c_hi: float = Field(..., ge=0.0, le=500.0) + phi: float = Field(..., ge=0.0, le=60.0) + phi_hi: float = Field(..., ge=0.0, le=60.0) + # 기본값 = 근거 후보(바꿀 수 있음 · 확정은 사용자) — 출처 = 지식DB 예정노선/02 §1.1 · + # SINMAP 2 설명서 기본 보정 · 국내 적용 연구(김동엽 2026 봉화 TRIGRS Cr 0~5 kPa · 우면산 1 kPa) + c_root: float = Field(1.0, ge=0.0, le=50.0) # 산림 뿌리 점착 · 나지 · 절성토면 = 0 + soil_depth_m: float = Field(1.5, ge=0.1, le=10.0) + scenario: Literal["sinmap", "saturated"] = "sinmap" # 표준(습윤지수) · 최악(완전 포화) + tr_lo: float = Field(2000.0, ge=1.0, le=1e5) # T/R(m) — SINMAP 기본 보정 2000~3000 + tr_hi: float = Field(3000.0, ge=1.0, le=1e5) + # 측점 등급 문턱(%) — 영향 구역 안 SI < 1 셀 비율 · 브레인 정함(사용자 확정 전) + level_lo_pct: float = Field(engine.LEVEL_LO_PCT, gt=0.0, le=100.0) + level_hi_pct: float = Field(engine.LEVEL_HI_PCT, gt=0.0, le=100.0) + force: bool = False + + @model_validator(mode="after") + def _ranges(self) -> AreaRequest: + if self.level_hi_pct < self.level_lo_pct: + raise ValueError("측점 등급 문턱: 높음이 보통보다 작습니다.") + if self.c_hi < self.c or self.phi_hi < self.phi or self.tr_hi < self.tr_lo: + raise ValueError("범위 위가 아래보다 작습니다(c · φ · T/R).") + return self + + +# --------------------------------------------------------------------------- +# 입력 · 지문 · 저장본 +# --------------------------------------------------------------------------- + + +def _ground(project_root: Path) -> dict[str, Any]: + for path in (ground_model.ground_path(project_root), ground_model.initial_path(project_root)): + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + return ground_model.pick(ground_model.load_defaults()) + + +def defaults(project_root: Path) -> dict[str, Any]: + """기본 조건 — E02 붕적토(없으면 첫 물성) · 범위 위 = + 5 kPa · + 3°.""" + materials = _ground(project_root).get("materials") or [] + mat = next((m for m in materials if m.get("id") == DEFAULT_MATERIAL), None) or materials[0] + return { + **{ + k: f.default + for k, f in AreaRequest.model_fields.items() + if not f.is_required() and k != "force" + }, + "material_id": mat.get("id"), + "gamma_t": float(mat["gamma_t"]), + "gamma_sat": float(mat["gamma_sat"]), + "c": float(mat["c"]), + "c_hi": float(mat["c"]) + RANGE_C_KPA, + "phi": float(mat["phi"]), + "phi_hi": float(mat["phi"]) + RANGE_PHI_DEG, + } + + +def fingerprint(source_fingerprint: str, settings: dict[str, Any]) -> str: + body = {"source": source_fingerprint, "settings": settings, "engine": ENGINE_VERSION} + text = json.dumps(body, ensure_ascii=False, sort_keys=True) + return hashlib.sha1(text.encode("utf-8")).hexdigest() + + +def load(project_root: Path) -> dict[str, Any] | None: + path = Path(project_root) / RESULT + return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + + +def _route_line(root: Path) -> np.ndarray: + points = sorted( + source.db_snapshot(root).get("route_points") or [], key=lambda p: p["sequence_num"] + ) + return np.array([[p["model_x"], p["model_y"], p["chainage_m"]] for p in points], float) + + +def _b64(values: np.ndarray, dtype: str) -> str: + return base64.b64encode(np.ascontiguousarray(values, dtype=dtype).tobytes()).decode("ascii") + + +def _u16(values: np.ndarray, scale: float, keep: np.ndarray) -> str: + v = np.round(np.clip(np.nan_to_num(values * scale, nan=-1.0, posinf=65534.0), -1, 65534)) + return _b64(np.where(keep & (v >= 0), v, 65535), " dict[str, Any]: + """해석 한 번 → 저장본 쓰고 요약 돌려줌.""" + started = time.perf_counter() + cell = float(settings["cell_m"]) + report(5, "지형 격자") + with np.load(source.terrain_grid_path(root)) as npz: + dem = {k: npz[k] for k in ("x", "y", "z", "valid_mask")} + z, x0, y0 = engine.resample_dem(dem, cell) + report(25, "경사 · 집수면적 · 안전율") + keys = ( + "gamma_t", + "gamma_sat", + "c", + "c_hi", + "phi", + "phi_hi", + "c_root", + "soil_depth_m", + "scenario", + "tr_lo", + "tr_hi", + ) + out = engine.analyze_grid(z, cell, engine.AreaParams(**{k: settings[k] for k in keys})) + report(75, "노선 띠 · 측점") + stations = sorted(source.stations(root), key=lambda s: float(s["chainage_m"])) + inside, chain = engine.band_cells( + z.shape, x0, y0, cell, _route_line(root), float(settings["band_m"]) + ) + rows = engine.station_table( + [s["chainage_m"] for s in stations], + chain, + inside, + out, + cell, + float(settings["level_lo_pct"]), + float(settings["level_hi_pct"]), + ) + labels = {round(float(s["chainage_m"]), 3): s.get("label") for s in stations} + for row in rows: + row["label"] = labels.get(round(row["chainage_m"], 3)) + # 띠 상자로 잘라 저장 + iy, ix = np.nonzero(inside) + if not len(iy): + raise ValueError("노선 띠가 지형 격자와 겹치지 않습니다.") + win = (slice(iy.min(), iy.max() + 1), slice(ix.min(), ix.max() + 1)) + keep = inside[win] & (out["cls"][win] > 0) + areas = engine.class_areas(out["cls"], inside, cell) + have = inside & (out["cls"] > 0) + summary = { + "areas_m2": areas, + "band_area_m2": round(float(inside.sum()) * cell**2, 1), + "covered_area_m2": round(float(have.sum()) * cell**2, 1), + "risk_area_m2": round( + sum(v for k, v in areas.items() if engine.CLASS_KEYS.index(k) >= engine.RISK_CLASS), 1 + ), + "fs_min": round(float(np.min(out["fs"][have])), 3) if have.any() else None, + "station_levels": {k: sum(r.get("level") == k for r in rows) for k in engine.LEVEL_KEYS}, + "stations": len(rows), + } + doc = { + "fingerprint": finger, + "engine": ENGINE_VERSION, + "settings": settings, + "computed_at": datetime.now().isoformat(timespec="seconds"), + "elapsed_s": round(time.perf_counter() - started, 2), + "grid": { + "x0": x0 + int(ix.min()) * cell, + "y0": y0 + int(iy.min()) * cell, + "dx": cell, + "nx": int(keep.shape[1]), + "ny": int(keep.shape[0]), + }, + "fs": _u16(out["fs"][win], 1000, keep), + "si": _u16(out["si"][win], 1000, keep), + "cls": _b64(np.where(keep, out["cls"][win], 0), "u1"), + "slope": _u16(np.degrees(out["slope"][win]), 10, keep), + "m": _b64(np.where(keep, np.round(np.nan_to_num(out["m"][win], nan=0) * 100), 255), "u1"), + "stations": rows, + "summary": summary, + } + report(95, "저장") + atomic_write_json(Path(project_root) / RESULT, doc) + return summary + + +# --------------------------------------------------------------------------- +# 길 +# --------------------------------------------------------------------------- + + +async def _project_root(project_id: UUID) -> Path: + async with ( + get_db_pool().acquire() as connection, + connection.cursor(aiomysql.DictCursor) as cursor, + ): + await cursor.execute( + "SELECT storage_path FROM projects" + " WHERE id = %s AND project_kind = 'STABILITY' AND deleted_at IS NULL", + (str(project_id),), + ) + row = await cursor.fetchone() + if not row or not row["storage_path"]: + raise HTTPException(status_code=404, detail="안정성평가 프로젝트가 아닙니다.") + return Path(resolve_stored_project_path(row["storage_path"])) + + +def _source_root(project_root: Path) -> Path: + root = source.source_dir(project_root) + if not (root / source.MANIFEST_NAME).is_file(): + raise HTTPException(status_code=409, detail="원본 복사본이 없습니다.") + return root + + +@router.post("/{project_id}/stab/area/analyze", response_model=None) +async def analyze(project_id: UUID, payload: AreaRequest) -> dict[str, Any]: + """[해석] — 지문이 같으면 `cached`(안 셈) · 다르거나 force 면 뒤에서 셈.""" + pid = str(project_id) + project_root = await _project_root(project_id) + root = _source_root(project_root) + settings = payload.model_dump(exclude={"force"}) + finger = fingerprint(source.manifest(root)["source_fingerprint"], settings) + saved = await asyncio.to_thread(load, project_root) + if not payload.force and saved and saved["fingerprint"] == finger: + return {"status": "cached", "summary": saved["summary"]} + if (_JOBS.get(pid) or {}).get("running"): + raise HTTPException(status_code=409, detail="이미 광역 해석이 돌고 있습니다.") + job = {**_IDLE, "running": True, "message": "시작"} + _JOBS[pid] = job + + def report(percent: int, message: str) -> None: + job.update(percent=max(0, min(99, int(percent))), message=message) + + async def work() -> None: + try: + await asyncio.to_thread(run, project_root, root, settings, finger, report) + job.update(percent=100, message="끝") + except Exception as error: # noqa: BLE001 — 진행률에 까닭을 그대로 올림 + logger.exception("E07 광역 해석 실패: project_id=%s", pid) + job["error"] = str(error) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E07 area {pid}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return {"status": "accepted"} + + +@router.get("/{project_id}/stab/area/progress", response_model=None) +async def progress(project_id: UUID) -> dict[str, Any]: + return {"status": "success", **dict(_JOBS.get(str(project_id)) or _IDLE)} + + +def _overview(project_root: Path, root: Path) -> dict[str, Any]: + saved = load(project_root) + if saved: + current = fingerprint(source.manifest(root)["source_fingerprint"], saved["settings"]) + saved["stale"] = saved["fingerprint"] != current + return { + "defaults": defaults(project_root), + "materials": _ground(project_root).get("materials") or [], + "line": _route_line(root).round(3).tolist(), + "stations": [ + { + "chainage_m": s["chainage_m"], + "label": s["label"], + "x": s["center_x"], + "y": s["center_y"], + } + for s in sorted(source.stations(root), key=lambda s: float(s["chainage_m"])) + ], + "result": saved, + } + + +async def _editable(project_id: UUID, session: dict[str, Any]) -> bool: + """[해석] 가능 = 시스템 관리자 · 같은 회사(지정받은 회사는 보기만).""" + if session.get("role") == "SYSTEM_ADMIN": + return True + async with get_db_pool().acquire() as connection, connection.cursor() as cursor: + await cursor.execute("SELECT company_id FROM projects WHERE id = %s", (str(project_id),)) + row = await cursor.fetchone() + return bool(row) and row[0] == session.get("company_id") + + +@router.get("/{project_id}/stab/area", response_model=None) +async def area( + project_id: UUID, session: dict[str, Any] = Depends(require_company) +) -> dict[str, Any]: + project_root = await _project_root(project_id) + root = _source_root(project_root) + overview = await asyncio.to_thread(_overview, project_root, root) + return {"status": "success", "editable": await _editable(project_id, session), **overview} diff --git a/E07_StabArea/E07_StabArea_UI_Map.ts b/E07_StabArea/E07_StabArea_UI_Map.ts new file mode 100644 index 000000000..b2a77b581 --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Map.ts @@ -0,0 +1,296 @@ +/* ============================================================================= + * E07_StabArea_UI_Map.ts + * E07 가운데 평면 지도(PLAN 536-2) — 등급(SI) · 안전율 색 격자 · 노선 띠 · 노선 · 측점 · 고른 측점 · + * 방위 · 축척 · 셀 툴팁. 휠 = 확대 · 끌기 = 옮김 · 두 번 누름 = 맞춤. 색 · 풀기 = logic/E07_StabArea_Raster. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster"; + +export type MapLayer = "cls" | "fs"; + +export interface StationPoint { + chainage_m: number; + label: string | null; + x: number; + y: number; +} + +export interface AreaMap { + root: HTMLElement; + setRoute: (line: number[][], stations: StationPoint[]) => void; + setBand: (halfWidthM: number) => void; + setResult: (data: Decoded | null) => void; + setLayer: (layer: MapLayer) => void; + focus: (chainage: number) => void; + dispose: () => void; +} + +interface View { + cx: number; + cy: number; + scale: number; // px / m +} + +const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); + +const css = (name: string, fallback: string): string => + getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + +export function createAreaMap(): AreaMap { + const root = el("div", { className: "e07-map" }); + const canvas = el("canvas", { className: "e07-map__canvas" }); + const tip = el("div", { className: "e07-map__tip" }); + const empty = el("p", { className: "e07-map__empty", text: t("E07_Map_Empty") }); + tip.hidden = true; + root.append(canvas, tip, empty); + const ctx = canvas.getContext("2d")!; + + let line: number[][] = []; + let stations: StationPoint[] = []; + let band = 100; + let data: Decoded | null = null; + let layer: MapLayer = "cls"; + let image: HTMLCanvasElement | null = null; + let picked: number | null = null; + let view: View = { cx: 0, cy: 0, scale: 1 }; + let width = 0; + let height = 0; + + const toScreen = (x: number, y: number): [number, number] => [ + width / 2 + (x - view.cx) * view.scale, + height / 2 - (y - view.cy) * view.scale, + ]; + const toModel = (px: number, py: number): [number, number] => [ + view.cx + (px - width / 2) / view.scale, + view.cy - (py - height / 2) / view.scale, + ]; + + function buildImage(): void { + image = null; + if (!data) return; + const { nx, ny } = data.grid; + const off = document.createElement("canvas"); + off.width = nx; + off.height = ny; + off.getContext("2d")!.putImageData(new ImageData(rasterRgba(data, layer), nx, ny), 0, 0); + image = off; + } + + function fit(): void { + const xs = line.map((p) => p[0]); + const ys = line.map((p) => p[1]); + if (!xs.length || !width || !height) return; + const pad = band; + const [x0, x1] = [Math.min(...xs) - pad, Math.max(...xs) + pad]; + const [y0, y1] = [Math.min(...ys) - pad, Math.max(...ys) + pad]; + const scale = Math.min(width / Math.max(x1 - x0, 1), height / Math.max(y1 - y0, 1)) * 0.92; + view = { cx: (x0 + x1) / 2, cy: (y0 + y1) / 2, scale }; + } + + function path(): void { + ctx.beginPath(); + line.forEach((p, i) => + i ? ctx.lineTo(...toScreen(p[0], p[1])) : ctx.moveTo(...toScreen(p[0], p[1])), + ); + } + + function drawRoute(text: string, accent: string): void { + if (line.length < 2) return; + ctx.lineJoin = ctx.lineCap = "round"; + // 노선 띠 — 중심선 ± band(옅은 띠) + ctx.globalAlpha = 0.12; + ctx.strokeStyle = accent; + ctx.lineWidth = Math.max(2 * band * view.scale, 1); + path(); + ctx.stroke(); + ctx.globalAlpha = 1; + ctx.strokeStyle = text; + ctx.lineWidth = 2; + path(); + ctx.stroke(); + const gap = stations.length > 1 ? 20 * view.scale : 999; + const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100; + ctx.font = "11px sans-serif"; + ctx.textBaseline = "middle"; + stations.forEach((s, i) => { + const [px, py] = toScreen(s.x, s.y); + if (px < -50 || py < -50 || px > width + 50 || py > height + 50) return; + const on = picked !== null && Math.abs(s.chainage_m - picked) < 1e-6; + ctx.fillStyle = on ? accent : text; + ctx.beginPath(); + ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2); + ctx.fill(); + if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9); + }); + } + + function drawFurniture(text: string): void { + ctx.fillStyle = text; + ctx.strokeStyle = text; + ctx.beginPath(); + ctx.moveTo(width - 24, 14); + ctx.lineTo(width - 30, 34); + ctx.lineTo(width - 18, 34); + ctx.closePath(); + ctx.fill(); + ctx.font = "bold 12px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText("N", width - 24, 46); + const target = 100 / view.scale; + const pow = 10 ** Math.floor(Math.log10(target)); + const meters = [1, 2, 5, 10].map((k) => k * pow).find((m) => m >= target) ?? target; + const px = meters * view.scale; + ctx.fillStyle = css("--color-surface", "#ffffff"); + ctx.globalAlpha = 0.85; + ctx.fillRect(width - 24 - px, height - 42, px + 16, 34); + ctx.globalAlpha = 1; + ctx.fillStyle = text; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(width - 16 - px, height - 16); + ctx.lineTo(width - 16, height - 16); + ctx.stroke(); + ctx.fillText(`${meters} m`, width - 16 - px / 2, height - 26); + ctx.textAlign = "start"; + } + + function draw(): void { + const dpr = window.devicePixelRatio || 1; + width = root.clientWidth; + height = root.clientHeight; + if (!width || !height) return; + if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) { + canvas.width = Math.round(width * dpr); + canvas.height = Math.round(height * dpr); + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + const text = css("--color-text", "#1f2937"); + const accent = css("--color-primary", "#6d28d9"); + if (data && image) { + const g = data.grid; + const [left, top] = toScreen(g.x0 - g.dx / 2, g.y0 + (g.ny - 0.5) * g.dx); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(image, left, top, g.nx * g.dx * view.scale, g.ny * g.dx * view.scale); + } + drawRoute(text, accent); + drawFurniture(text); + empty.hidden = Boolean(data); + } + + let drag: { x: number; y: number; view: View } | null = null; + canvas.addEventListener("wheel", (event) => { + event.preventDefault(); + const rect = canvas.getBoundingClientRect(); + const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); + const k = Math.exp(-event.deltaY * 0.0015); + view = { scale: view.scale * k, cx: mx - (mx - view.cx) / k, cy: my - (my - view.cy) / k }; + draw(); + }); + canvas.addEventListener("pointerdown", (event) => { + drag = { x: event.clientX, y: event.clientY, view: { ...view } }; + canvas.setPointerCapture(event.pointerId); + }); + canvas.addEventListener("pointerup", () => (drag = null)); + canvas.addEventListener("dblclick", () => { + fit(); + draw(); + }); + canvas.addEventListener("pointerleave", () => (tip.hidden = true)); + canvas.addEventListener("pointermove", (event) => { + if (drag) { + view = { + ...drag.view, + cx: drag.view.cx - (event.clientX - drag.x) / view.scale, + cy: drag.view.cy + (event.clientY - drag.y) / view.scale, + }; + draw(); + return; + } + const rect = canvas.getBoundingClientRect(); + const [mx, my] = toModel(event.clientX - rect.left, event.clientY - rect.top); + const i = data ? cellIndex(data.grid, mx, my) : -1; + if (i < 0 || !data!.cls[i]) return void (tip.hidden = true); + const near = stations.reduce( + (best, s) => + !best || (s.x - mx) ** 2 + (s.y - my) ** 2 < (best.x - mx) ** 2 + (best.y - my) ** 2 + ? s + : best, + null, + ); + const m = data!.m[i]; + tip.textContent = [ + t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey), + `SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`, + `${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`, + near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "", + ] + .filter(Boolean) + .join("\n"); + tip.style.left = `${event.clientX - rect.left + 14}px`; + tip.style.top = `${event.clientY - rect.top + 14}px`; + tip.hidden = false; + }); + + const resize = new ResizeObserver(() => { + const first = !width; + draw(); + if (first) { + fit(); + draw(); + } + }); + resize.observe(root); + const theme = new MutationObserver(draw); + theme.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); + + // 검증 창구 — 모델 좌표 → 화면(client) 좌표 · 지금 격자 · 층 + const probe = { + project: (x: number, y: number) => { + const rect = canvas.getBoundingClientRect(); + const [px, py] = toScreen(x, y); + return { x: rect.left + px, y: rect.top + py }; + }, + data: () => data, + layer: () => layer, + }; + (window as unknown as { __e07Map?: typeof probe }).__e07Map = probe; + + return { + root, + setRoute(nextLine, nextStations) { + line = nextLine; + stations = nextStations; + fit(); + draw(); + }, + setBand(halfWidthM) { + band = halfWidthM; + draw(); + }, + setResult(next) { + data = next; + buildImage(); + draw(); + }, + setLayer(next) { + layer = next; + buildImage(); + draw(); + }, + focus(chainage) { + picked = chainage; + const s = stations.find((x) => Math.abs(x.chainage_m - chainage) < 1e-6); + if (s) view = { ...view, cx: s.x, cy: s.y }; + draw(); + }, + dispose() { + resize.disconnect(); + theme.disconnect(); + if ((window as unknown as { __e07Map?: unknown }).__e07Map === probe) + delete (window as unknown as { __e07Map?: unknown }).__e07Map; + }, + }; +} diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts new file mode 100644 index 000000000..e23acd3bb --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -0,0 +1,497 @@ +/* ============================================================================= + * E07_StabArea_UI_Page.ts + * E07 광역 자연사면 위험 지도(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2) — B05~B09 틀(group_workflow). + * 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률 + * 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례 + * 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점) + * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage. + * ========================================================================== */ + +import "./E07_StabArea_UI_Style.css"; +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, + type InputFieldHandle, +} from "@ui/ui_template_elements"; +import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { createSidePanels } from "@ui/ui_template_side_panels"; +import { mountGroupStep } from "../A00_Common/group_workflow"; +import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map"; +import { + CLASS_COLORS, + CLASS_KEYS, + FS_STEPS, + LEVEL_COLORS, + LEVEL_KEYS, + decode, + type AreaResult, +} from "./logic/E07_StabArea_Raster"; + +interface Material { + id: string; + name: string; + gamma_t: number; + gamma_sat: number; + c: number; + phi: number; +} + +type Settings = Record; + +interface Overview { + editable: boolean; + defaults: Settings; + materials: Material[]; + line: number[][]; + stations: StationPoint[]; + result: AreaResult | null; +} + +interface Progress { + running: boolean; + percent: number; + message: string | null; + error: string | null; +} + +const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ + ["gamma_t", "E07_GammaT", "soil"], + ["gamma_sat", "E07_GammaSat", "soil"], + ["c", "E07_C", "soil"], + ["c_hi", "E07_CHi", "soil"], + ["phi", "E07_Phi", "soil"], + ["phi_hi", "E07_PhiHi", "soil"], + ["c_root", "E07_CRoot", "soil"], + ["soil_depth_m", "E07_Depth", "soil"], + ["tr_lo", "E07_TrLo", "rain"], + ["tr_hi", "E07_TrHi", "rain"], + ["band_m", "E07_Band", "band"], + ["cell_m", "E07_Cell", "band"], + ["level_lo_pct", "E07_LevelLo", "band"], + ["level_hi_pct", "E07_LevelHi", "band"], +]; + +const cacheKey = (projectId: string): string => `e07_settings:${projectId}`; +const shortLabel = (label: string | null, ch: number): string => + (label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, ""); + +async function api(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 T & { detail?: unknown }; + if (!response.ok) { + const detail = body.detail; + const text = Array.isArray(detail) + ? detail.map((d) => (d as { msg?: string }).msg ?? "").join(" · ") + : detail; + throw new Error(typeof text === "string" && text ? text : `HTTP ${response.status}`); + } + return body; +} + +function swatch(color: string): HTMLElement { + const chip = el("i", { className: "e07-chip" }); + chip.style.background = color; + return chip; +} + +const SCENARIOS = ["sinmap", "saturated"]; +const scenarioName = (value: unknown): string => + SCENARIOS.includes(String(value)) ? t(`E07_Scenario_${value}` as LocaleKey) : String(value ?? ""); + +function legend(layer: MapLayer, scenario: unknown): HTMLElement { + const box = el("div", { className: "e07-legend" }); + box.append(el("strong", { text: t(layer === "cls" ? "E07_Legend_Class" : "E07_Legend_FS") })); + if (scenario) + box.append(el("small", { className: "e07-legend__sub", text: scenarioName(scenario) })); + const rows = + layer === "cls" + ? CLASS_KEYS.map((k, i) => ({ + color: CLASS_COLORS[i], + label: t(`E07_Class_${k}` as LocaleKey), + })) + : FS_STEPS.map((s, i) => ({ + color: s.color, + label: + i === 0 + ? `< ${s.below.toFixed(1)}` + : s.below === Infinity + ? `≥ ${FS_STEPS[i - 1].below.toFixed(1)}` + : `${FS_STEPS[i - 1].below.toFixed(1)} – ${s.below.toFixed(1)}`, + })); + for (const row of rows) { + const item = el("span", { className: "e07-legend__row" }); + item.append(swatch(row.color), el("span", { text: row.label })); + box.append(item); + } + return box; +} + +export function renderE07StabArea(root: HTMLElement): void { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); + const panel = el("div", { className: "ui-sidebar-panel e07-panel" }); + const main = el("div", { className: "e07-page" }); + mountGroupStep(root, ROUTES.E07_STAB_AREA, { mainContent: main, leftPanel: panel }); + if (!projectId) + return void main.append(el("p", { className: "e07-empty", text: t("E07_NoProject") })); + + // ---- 가운데 ---- + const map = createAreaMap(); + let overview: Overview | null = null; + let layer: MapLayer = "cls"; + const legendSlot = el("div", { className: "e07-legend-slot" }); + const paintLegend = (): void => + legendSlot.replaceChildren(legend(layer, overview?.result?.settings.scenario)); + const toggle = el("div", { className: "e07-toggle" }); + const layerButtons = ( + [ + ["E07_Layer_Class", "cls"], + ["E07_Layer_FS", "fs"], + ] as [LocaleKey, MapLayer][] + ).map(([key, value], i) => { + const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); + button.dataset.value = value; + button.classList.toggle("is-on", i === 0); + button.addEventListener("click", () => { + layerButtons.forEach((b) => b.classList.toggle("is-on", b === button)); + layer = value; + map.setLayer(layer); + paintLegend(); + }); + return button; + }); + toggle.append(...layerButtons); + const stage = el("div", { className: "e07-stage" }); + stage.append(map.root, legendSlot); + const center = el("div", { className: "e07-center" }); + center.append(toggle, stage); + paintLegend(); + + // ---- 우 ---- + const resultBody = el("div", { className: "e07-result" }); + const side = createSidePanels({ + prefKey: "e07-stab-area-result", + panels: [{ id: "result", title: t("E07_Result_Title"), body: resultBody }], + defaultOpen: "result", + defaultWidth: 380, + follow: false, + }); + const row = el("div", { className: "ui-side-panels-row e07-row" }); + row.append(center, side.root); + main.append(row); + + // ---- 좌 ---- + const fields = new Map(); + const cards = { + soil: createPanelCard(t("E07_Card_Soil"), false), + rain: createPanelCard(t("E07_Card_Rain"), false), + band: createPanelCard(t("E07_Card_Band"), false), + }; + const material = createSelectField({ label: t("E07_Material"), options: [], compact: true }); + const scenario = createSelectField({ + label: t("E07_Scenario"), + options: SCENARIOS.map((value) => ({ value, text: scenarioName(value) })), + compact: true, + }); + cards.soil.append(material.root); + cards.rain.append(scenario.root); + const sourceHint = el("p", { className: "e07-hint", text: t("E07_Defaults_Source") }); + for (const [key, label, card] of NUMBER_FIELDS) { + const field = createInputField({ label: t(label), type: "number", compact: true }); + field.input.step = "any"; + field.input.dataset.key = key; + fields.set(key, field); + cards[card].append(field.root); + } + const progressBar = el("progress", { className: "e07-progress", attrs: { max: "100" } }); + const progressText = el("p", { className: "e07-progress__text" }); + const progressBox = el("div", { className: "e07-progress-box" }); + progressBox.append(progressBar, progressText); + progressBox.hidden = true; + const defaultsButton = createButton({ + label: t("E07_Defaults"), + variant: "ghost", + onClick: () => overview && write(overview.defaults), + }); + const analyzeButton = createButton({ + label: t("E07_Analyze"), + variant: "filled", + onClick: () => void analyze(false), + }); + const reanalyzeButton = createButton({ + label: t("E07_Reanalyze"), + variant: "ghost", + onClick: () => void analyze(true), + }); + const actions = el("div", { className: "ui-sidebar-actions" }); + actions.append(defaultsButton, reanalyzeButton, analyzeButton); + const readOnly = el("p", { className: "e07-hint", text: t("E07_ReadOnly") }); + readOnly.hidden = true; + panel.append( + el("p", { className: "e07-hint", text: t("E07_Hint") }), + readOnly, + cards.soil, + cards.rain, + cards.band, + sourceHint, + progressBox, + actions, + ); + attachCollapsible(panel); + + // ---- 상태 ---- + let running = false; + + function read(): Settings { + const out: Settings = { material_id: material.select.value || null }; + out.scenario = scenario.select.value; + for (const [key, field] of fields) out[key] = Number(field.input.value); + return out; + } + + function write(values: Settings): void { + for (const [key, field] of fields) field.input.value = String(values[key] ?? ""); + material.select.value = String(values.material_id ?? ""); + scenario.select.value = SCENARIOS.includes(String(values.scenario)) + ? String(values.scenario) + : SCENARIOS[0]; + paintScenario(); + remember(); + } + + function paintScenario(): void { + const wet = scenario.select.value === "sinmap"; + for (const key of ["tr_lo", "tr_hi"]) fields.get(key)!.root.hidden = !wet; + map.setBand(Number(fields.get("band_m")!.input.value) || 100); + } + + const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(read())); + panel.addEventListener("input", remember); + scenario.select.addEventListener("change", () => { + paintScenario(); + remember(); + }); + fields.get("band_m")!.input.addEventListener("input", paintScenario); + material.select.addEventListener("change", () => { + const m = overview?.materials.find((x) => x.id === material.select.value); + if (!m) return; + const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v)); + const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c); + const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi); + set("gamma_t", m.gamma_t); + set("gamma_sat", m.gamma_sat); + set("c", m.c); + set("c_hi", m.c + rangeC); + set("phi", m.phi); + set("phi_hi", m.phi + rangePhi); + remember(); + }); + + function paintButtons(): void { + const editable = Boolean(overview?.editable); + analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + readOnly.hidden = !overview || editable; + } + + function paintResult(): void { + const result = overview?.result; + if (!result) { + resultBody.replaceChildren(el("p", { className: "e07-empty", text: t("E07_Result_None") })); + return; + } + const s = result.summary; + const list = el("dl", { className: "e07-summary" }); + const pairs: [LocaleKey, string][] = [ + ["E07_Scenario", scenarioName(result.settings.scenario)], + ["E07_Sum_FsMin", s.fs_min === null ? "-" : s.fs_min.toFixed(3)], + ["E07_Sum_Risk", `${Math.round(s.risk_area_m2).toLocaleString()} m²`], + [ + "E07_Sum_Levels", + `${LEVEL_KEYS.map((k) => `${t(`E07_Level_${k}` as LocaleKey)} ${s.station_levels[k]}`).join(" · ")} / ${s.stations}`, + ], + [ + "E07_Sum_Covered", + `${Math.round(s.covered_area_m2).toLocaleString()} / ${Math.round(s.band_area_m2).toLocaleString()} m²`, + ], + [ + "E07_Sum_Time", + `${result.elapsed_s.toFixed(1)} s · ${result.computed_at.replace("T", " ")}`, + ], + ]; + for (const [key, value] of pairs) + list.append( + el("dt", { text: t(key) }), + el("dd", { text: value, attrs: { "data-field": key } }), + ); + const areas = el("table", { className: "e07-table e07-areas" }); + const total = Object.values(s.areas_m2).reduce((a, b) => a + b, 0) || 1; + areas.append( + el("tr", { + children: [ + el("th", { text: t("E07_Col_Class") }), + el("th", { text: t("E07_Col_Area") }), + el("th", { text: "%" }), + ], + }), + ...CLASS_KEYS.map((k, i) => { + const name = el("td"); + name.append( + swatch(CLASS_COLORS[i]), + el("span", { text: t(`E07_Class_${k}` as LocaleKey) }), + ); + return el("tr", { + attrs: { "data-class": k }, + children: [ + name, + el("td", { text: Math.round(s.areas_m2[k] ?? 0).toLocaleString() }), + el("td", { text: (((s.areas_m2[k] ?? 0) / total) * 100).toFixed(1) }), + ], + }); + }), + ); + const table = el("table", { className: "e07-table e07-stations" }); + table.append( + el("tr", { + children: [ + el("th", { text: t("E07_Col_Station") }), + el("th", { text: t("E07_Col_FsMin") }), + el("th", { text: t("E07_Col_Ratio") }), + el("th", { text: t("E07_Col_Level") }), + el("th", { text: t("E07_Col_RiskArea") }), + ], + }), + ...result.stations.map((r) => { + const level = el("td"); + if (r.level) + level.append( + swatch(LEVEL_COLORS[r.level]), + el("span", { text: t(`E07_Level_${r.level}` as LocaleKey) }), + ); + const tr = el("tr", { + className: r.level === "high" ? "is-risk" : "", + attrs: { "data-chainage": String(r.chainage_m) }, + children: [ + el("td", { text: shortLabel(r.label, r.chainage_m) }), + el("td", { text: r.fs_min === undefined ? "-" : r.fs_min.toFixed(2) }), + el("td", { + text: r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1), + }), + level, + el("td", { + text: + r.risk_area_m2 === undefined ? "-" : Math.round(r.risk_area_m2).toLocaleString(), + }), + ], + }); + tr.addEventListener("click", () => { + table.querySelectorAll("tr.is-on").forEach((x) => x.classList.remove("is-on")); + tr.classList.add("is-on"); + map.focus(r.chainage_m); + }); + return tr; + }), + ); + const notes: HTMLElement[] = []; + if (result.stale) notes.push(el("p", { className: "e07-warn", text: t("E07_Result_Stale") })); + notes.push(el("p", { className: "e07-note", text: t("E07_Result_Note") })); + resultBody.replaceChildren( + list, + el("h4", { text: t("E07_Areas_Title") }), + areas, + el("h4", { text: t("E07_Stations_Title") }), + table, + ...notes, + ); + } + + async function refresh(): Promise { + overview = await api(`/projects/${projectId}/stab/area`); + material.setOptions( + overview.materials.map((m) => ({ value: m.id, text: m.name })), + String(overview.defaults.material_id ?? ""), + ); + map.setResult(overview.result ? decode(overview.result) : null); + paintButtons(); + paintResult(); + paintLegend(); + } + + async function poll(): Promise { + while (root.isConnected) { + const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); + if (!p) break; + running = p.running; + progressBox.hidden = !p.running; + progressBar.value = p.percent; + progressText.textContent = `${t("E07_Running")} ${p.percent}% · ${p.message ?? ""}`; + paintButtons(); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else showToast(t("E07_Done"), "success"); + await refresh(); + return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + async function analyze(force: boolean): Promise { + try { + const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, { + method: "POST", + body: JSON.stringify({ ...read(), force }), + }); + if (body.status === "cached") { + showToast(t("E07_Cached"), "info"); + await refresh(); + return; + } + running = true; + paintButtons(); + progressBox.hidden = false; + void poll(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } + } + + // ---- 처음 ---- + paintButtons(); + void (async () => { + try { + await refresh(); + } catch (error) { + main.replaceChildren( + el("p", { + className: "e07-empty", + text: error instanceof Error ? error.message : String(error), + }), + ); + return; + } + const ov = overview!; + const cached = JSON.parse( + sessionStorage.getItem(cacheKey(projectId)) ?? "null", + ) as Settings | null; + // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 + write({ ...ov.defaults, ...(cached ?? (ov.result?.settings as Settings | undefined)) }); + map.setRoute(ov.line, ov.stations); + const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); + if (p?.running) void poll(); + })(); + + const leave = new MutationObserver(() => { + if (root.isConnected && main.isConnected) return; + leave.disconnect(); + map.dispose(); + }); + leave.observe(document.body, { childList: true, subtree: true }); +} diff --git a/E07_StabArea/E07_StabArea_UI_Style.css b/E07_StabArea/E07_StabArea_UI_Style.css new file mode 100644 index 000000000..55436dfab --- /dev/null +++ b/E07_StabArea/E07_StabArea_UI_Style.css @@ -0,0 +1,236 @@ +/* ============================================================================= + * E07_StabArea_UI_Style.css + * E07 광역 위험 지도 — 좌측 조건 · 진행 / 가운데 지도 · 범례 / 우측 요약 · 측점 표 + * 색은 테마 토큰만(밝음 · 어두움 같이) · 등급 · 안전율 색만 logic 고정색 + * ========================================================================== */ + +.e07-page { + height: 100dvh; + box-sizing: border-box; +} + +.e07-row { + height: 100%; +} + +.e07-center { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + height: 100%; + min-height: 0; + padding: var(--spacing-12) 0 var(--spacing-12) var(--spacing-12); + box-sizing: border-box; +} + +.e07-toggle { + display: inline-flex; + gap: var(--spacing-4); +} + +.e07-toggle .ui-btn.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-primary-text); +} + +.e07-stage { + position: relative; + flex: 1 1 auto; + min-height: 320px; + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + overflow: hidden; +} + +.e07-map { + position: absolute; + inset: 0; +} + +.e07-map__canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; + cursor: grab; +} + +.e07-map__tip { + position: absolute; + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; + white-space: pre; + pointer-events: none; +} + +.e07-map__empty { + position: absolute; + inset: auto 0 45% 0; + margin: 0; + text-align: center; + color: var(--color-text-muted); + pointer-events: none; +} + +.e07-legend-slot { + position: absolute; + left: var(--spacing-8); + bottom: var(--spacing-8); + pointer-events: none; +} + +.e07-legend { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--spacing-8); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + font-size: 12px; +} + +.e07-legend__sub { + max-width: 180px; + color: var(--color-text-muted); +} + +.e07-legend__row { + display: flex; + align-items: center; + gap: var(--spacing-8); +} + +.e07-chip { + display: inline-block; + width: 14px; + height: 10px; + flex: none; + margin-right: var(--spacing-4); + border-radius: 2px; + border: 1px solid var(--color-border); + vertical-align: middle; +} + +/* 좌측 */ +.e07-hint, +.e07-note { + margin: 0 0 var(--spacing-8); + color: var(--color-text-muted); + font-size: 12px; + line-height: 1.5; +} + +.e07-panel .ui-field[hidden] { + display: none; +} + +.e07-progress-box { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} + +.e07-progress-box[hidden] { + display: none; +} + +.e07-progress { + width: 100%; +} + +.e07-progress__text { + margin: 0; + color: var(--color-text); + font-size: 12px; +} + +/* 우측 */ +.e07-result { + padding: var(--spacing-12); + color: var(--color-text); +} + +.e07-result h4 { + margin: var(--spacing-16) 0 var(--spacing-8); + font-size: 13px; +} + +.e07-summary { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-8) var(--spacing-12); + margin: 0; + font-size: 13px; +} + +.e07-summary dt { + color: var(--color-text-muted); +} + +.e07-summary dd { + margin: 0; + font-weight: 600; + text-align: right; +} + +.e07-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.e07-table th, +.e07-table td { + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + text-align: right; + white-space: nowrap; +} + +.e07-table th:first-child, +.e07-table td:first-child, +.e07-stations td:nth-child(4) { + text-align: left; +} + +.e07-table th { + color: var(--color-text-muted); + font-weight: 600; +} + +.e07-stations tr[data-chainage] { + cursor: pointer; +} + +.e07-stations tr[data-chainage]:hover { + background: var(--color-surface-raised); +} + +.e07-stations tr.is-risk td { + color: var(--color-danger); + font-weight: 600; +} + +.e07-stations tr.is-on { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.e07-warn { + margin: var(--spacing-12) 0 0; + color: var(--color-danger); + font-size: 12px; +} + +.e07-empty { + margin: var(--spacing-16); + color: var(--color-text-muted); +} diff --git a/E07_StabArea/draft/E07_StabArea_Route.ts b/E07_StabArea/draft/E07_StabArea_Route.ts new file mode 100644 index 000000000..08b2db164 --- /dev/null +++ b/E07_StabArea/draft/E07_StabArea_Route.ts @@ -0,0 +1,16 @@ +/* ============================================================================= + * E07_StabArea_Route.ts + * 길 선언(PLAN 536-2) — 라우터가 페이지 폴더마다 draft/*_Route.ts 를 glob 해 등록. + * 로그인 · 회사 · 현재 프로젝트 필요 · 꼬리말 없음 · 본문 = E07 광역 위험 지도 페이지 + * ========================================================================== */ + +import { ROUTES } from "@config/config_frontend"; +import type { DraftRoute } from "../../A00_Common/router"; + +export const ROUTE: DraftRoute = { + path: ROUTES.E07_STAB_AREA, + workflow: true, + project: true, + footerless: true, + load: async () => (await import("../E07_StabArea_UI_Page")).renderE07StabArea, +}; diff --git a/E07_StabArea/logic/E07_StabArea_Raster.ts b/E07_StabArea/logic/E07_StabArea_Raster.ts new file mode 100644 index 000000000..88ad25692 --- /dev/null +++ b/E07_StabArea/logic/E07_StabArea_Raster.ts @@ -0,0 +1,164 @@ +/* ============================================================================= + * E07_StabArea_Raster.ts + * E07 광역 위험 지도 화면 계산(PLAN 536-2) — 저장본 격자 풀기 · 등급 · 안전율 색 · RGBA · 셀 찾기. + * 격자 꼴 = 서버 `E07_StabArea_Router.py` 머리 주석(fs · si uint16 ×1000 · cls uint8 · slope ×10 · + * m ×100 · 작은 끝 base64 · 행 0 = 남쪽). 순수 함수만(DOM · fetch 금지). + * ========================================================================== */ + +export interface Grid { + x0: number; + y0: number; + dx: number; + nx: number; + ny: number; +} + +export type LevelKey = "low" | "mid" | "high"; + +export interface StationRow { + chainage_m: number; + label: string | null; + cells: number; + fs_min?: number; + si_min?: number; + worst_class?: number; + risk_area_m2?: number; + risk_ratio?: number; + level?: LevelKey; + slope_max_deg?: number; +} + +export interface AreaSummary { + areas_m2: Record; + band_area_m2: number; + covered_area_m2: number; + risk_area_m2: number; + fs_min: number | null; + station_levels: Record; + stations: number; +} + +export interface AreaResult { + stale?: boolean; + computed_at: string; + elapsed_s: number; + settings: Record; + grid: Grid; + fs: string; + si: string; + cls: string; + slope: string; + m: string; + stations: StationRow[]; + summary: AreaSummary; +} + +/** 풀어 둔 격자 — 자료 없음 = NaN(fs · si · slope · m) · 0(cls) */ +export interface Decoded { + grid: Grid; + fs: Float32Array; + si: Float32Array; + cls: Uint8Array; + slope: Float32Array; + m: Float32Array; +} + +/** 등급 열쇠(서버 CLASS_KEYS 차례 · 1~6) */ +export const CLASS_KEYS = ["stable", "moderate", "quasi", "lower", "upper", "defended"] as const; +export const RISK_CLASS = 4; +/** 측점 등급(서버 LEVEL_KEYS 차례) · 색 */ +export const LEVEL_KEYS: readonly LevelKey[] = ["low", "mid", "high"]; +export const LEVEL_COLORS: Record = { + low: "#1a9850", + mid: "#fee08b", + high: "#d73027", +}; + +/** 등급 색 1~6 — SINMAP 관례(초록 → 빨강) */ +export const CLASS_COLORS = ["#1a9850", "#91cf60", "#d9ef8b", "#fee08b", "#fc8d59", "#d73027"]; + +/** 안전율 색 단계 — 1.0 · 1.2 · 1.5 경계 */ +export const FS_STEPS: readonly { below: number; color: string }[] = [ + { below: 1.0, color: "#a50026" }, + { below: 1.2, color: "#f46d43" }, + { below: 1.5, color: "#fee08b" }, + { below: 2.0, color: "#a6d96a" }, + { below: Infinity, color: "#1a9850" }, +]; + +function bytes(text: string): Uint8Array { + const raw = atob(text); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i += 1) out[i] = raw.charCodeAt(i); + return out; +} + +function u16(text: string, scale: number): Float32Array { + const b = bytes(text); + const view = new DataView(b.buffer); + const out = new Float32Array(b.length / 2); + for (let i = 0; i < out.length; i += 1) { + const v = view.getUint16(i * 2, true); + out[i] = v === 65535 ? NaN : v / scale; + } + return out; +} + +export function decode(result: AreaResult): Decoded { + const m = bytes(result.m); + return { + grid: result.grid, + fs: u16(result.fs, 1000), + si: u16(result.si, 1000), + cls: bytes(result.cls), + slope: u16(result.slope, 10), + m: Float32Array.from(m, (v) => (v === 255 ? NaN : v / 100)), + }; +} + +function rgb(hex: string): [number, number, number] { + const n = Number.parseInt(hex.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function fsColor(value: number): string { + return (FS_STEPS.find((s) => value < s.below) ?? FS_STEPS[FS_STEPS.length - 1]).color; +} + +/** 격자 → RGBA(줄 0 = 북쪽 끝 · 캔버스 그림용) · 자료 없음 = 투명 */ +export function rasterRgba(data: Decoded, layer: "cls" | "fs"): Uint8ClampedArray { + const { nx, ny } = data.grid; + const out = new Uint8ClampedArray(nx * ny * 4); + const classRgb = CLASS_COLORS.map(rgb); + const fsRgb = FS_STEPS.map((s) => rgb(s.color)); + for (let iy = 0; iy < ny; iy += 1) { + const row = ny - 1 - iy; + for (let ix = 0; ix < nx; ix += 1) { + const i = iy * nx + ix; + const k = data.cls[i]; + if (!k) continue; + const j = FS_STEPS.findIndex((s) => data.fs[i] < s.below); + const color = layer === "cls" ? classRgb[k - 1] : fsRgb[j < 0 ? fsRgb.length - 1 : j]; + const at = (row * nx + ix) * 4; + out[at] = color[0]; + out[at + 1] = color[1]; + out[at + 2] = color[2]; + out[at + 3] = 255; + } + } + return out; +} + +/** 모델 좌표 → 셀 번호(격자 밖 = -1) */ +export function cellIndex(grid: Grid, x: number, y: number): number { + const ix = Math.round((x - grid.x0) / grid.dx); + const iy = Math.round((y - grid.y0) / grid.dx); + return ix < 0 || iy < 0 || ix >= grid.nx || iy >= grid.ny ? -1 : iy * grid.nx + ix; +} + +/** 등급별 셀 수(1~6) — 범례 · 검증용 */ +export function classCounts(data: Decoded): number[] { + const out = [0, 0, 0, 0, 0, 0]; + for (const k of data.cls) if (k) out[k - 1] += 1; + return out; +} diff --git a/main.py b/main.py index d9042e54b..cd8ab38f4 100644 --- a/main.py +++ b/main.py @@ -72,6 +72,22 @@ from B08_Quantity.B08_Quantity_Router_Summary import router as b08_summary_route from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router +from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_dashboard_router +from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router +from D02_PlanBase.D02_PlanBase_Router import router as d02_plan_base_router +from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router +from D04_PlanConfirm.D04_PlanConfirm_Link import dashboard_router as d04_link_dashboard_router +from D04_PlanConfirm.D04_PlanConfirm_Link import router as d04_plan_link_router +from D04_PlanConfirm.D04_PlanConfirm_Router import router as d04_plan_confirm_router +from E01_StabRegister.E01_StabRegister_Router import router as e01_stab_register_router +from E02_StabGround.E02_StabGround_Router import router as e02_stab_ground_router +from E04_Stab3D.E04_Stab3D_Router import router as e04_stab_3d_router +from E05_StabReport.E05_StabReport_Router import router as e05_stab_report_router +from E07_StabArea.E07_StabArea_Router import router as e07_stab_area_router +from E03_StabSection.E03_StabSection_Router import router as e03_stab_section_router +from E03_StabSection.E03_StabSection_Router_WhatIf import router as e03_stab_whatif_router +from E03_StabSection.E03_StabSection_Router_Prob import router as e03_stab_prob_router +from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_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 @@ -94,6 +110,7 @@ from common_util.common_util_auth import ( # 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). from common_util.common_util_dev_unlock_router import router as dev_unlock_router from common_util.common_util_help_router import router as help_router +from common_util.common_util_map_router import router as map_router from common_util.common_util_resource_monitor import sample_resources_loop from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop from config.config_db import close_db_pool, get_db_pool, init_db_pool @@ -449,6 +466,11 @@ app.include_router(a09_security_router) app.include_router(b01_dashboard_router) app.include_router(b02_proj_register_router) protected = [Depends(verify_session)] +# 신규 그룹 등록(요구 345) — 권한은 길 안에서(산림청 · 회사). +app.include_router(d01_plan_register_router, dependencies=protected) +app.include_router(d01_dashboard_router, dependencies=protected) +app.include_router(d04_link_dashboard_router, dependencies=protected) +app.include_router(e01_stab_register_router, dependencies=protected) # 프로젝트 경로가 붙는 라우터는 그 프로젝트가 내 회사 것인지도 함께 본다 # (2026-09-01 — 지금까지 id만 알면 남의 회사 자료에 닿았다). protected_with_company = [ @@ -468,6 +490,18 @@ app.include_router(b04_basins_router, dependencies=protected_with_company) 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(d02_plan_base_router, dependencies=protected_with_company) +app.include_router(d03_plan_analysis_router, dependencies=protected_with_company) +app.include_router(d04_plan_confirm_router, dependencies=protected_with_company) +app.include_router(d04_plan_link_router, dependencies=protected_with_company) +app.include_router(e02_stab_ground_router, dependencies=protected_with_company) +app.include_router(e04_stab_3d_router, dependencies=protected_with_company) +app.include_router(e05_stab_report_router, dependencies=protected_with_company) +app.include_router(e07_stab_area_router, dependencies=protected_with_company) +app.include_router(e03_stab_section_router, dependencies=protected_with_company) +app.include_router(e03_stab_whatif_router, dependencies=protected_with_company) +app.include_router(e03_stab_prob_router, dependencies=protected_with_company) +app.include_router(e06_stab_rock_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) @@ -517,6 +551,7 @@ app.include_router(m02_project_items_router, dependencies=protected) app.include_router(dev_unlock_router, dependencies=protected_with_company) # [?] 설명서 글(54-2) — 로그인만 · 파일을 부를 때마다 읽음. app.include_router(help_router, dependencies=protected) +app.include_router(map_router, dependencies=protected) # ───────────────────────────────────────────────────────────────────────── diff --git a/package.json b/package.json index 44e4b3b76..8d74a12f5 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "build:b07-extent": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Cross_Extent_Node.ts --outDir ../config/b07_extent_node", "build:b07-note": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B07_DesignDetail/B07_DesignDetail_Note_Options_Node.ts --outDir ../config/b07_note_node", "build:design-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../A00_Common/design/logic/design_calc_node.ts --outDir ../config/design_calc_node", + "build:e03-walls": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../E03_StabSection/E03_StabSection_Walls_Node.ts --outDir ../config/e03_walls_node", "install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install", "build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build", "preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner", diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 000000000..b42b983f2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# 개발 · 시험 전용 — 배포 서버에는 설치하지 않음 +# PyMuPDF(AGPL) — 지식DB resources/knowledge/original/_pipeline · PDF 읽는 시험만 +-r requirements.txt +PyMuPDF==1.28.2 diff --git a/requirements.txt b/requirements.txt index befd6bc20..d91f21859 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,6 @@ playwright==1.62.0 psutil>=6.1,<7 pydantic==2.5.0 pydantic_core==2.14.1 -PyMuPDF==1.28.2 PyMySQL==1.2.0 pyparsing==3.3.2 pyproj==3.7.2 diff --git a/tsconfig.json b/tsconfig.json index 30bf838b6..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*", "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"] }