Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1
This commit is contained in:
@@ -199,6 +199,50 @@ def _merge_irregular_into_longitudinal(
|
|||||||
atomic_write_json(path, data)
|
atomic_write_json(path, data)
|
||||||
|
|
||||||
|
|
||||||
|
#: 지표 샘플링 조건을 적어 두는 파일 — **나중에 측점을 더 만들 때 같은 조건으로** 뜨기 위해.
|
||||||
|
#: ⚠ 조건이 다르면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
|
||||||
|
SAMPLING_SNAPSHOT_NAME = "sampling.json"
|
||||||
|
|
||||||
|
|
||||||
|
def sampling_snapshot_path(project_root: Path) -> Path:
|
||||||
|
return project_root / "B06_Section" / SAMPLING_SNAPSHOT_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def save_sampling_snapshot(project_root: Path, request: RouteConfirmRequest) -> None:
|
||||||
|
"""확정 때 쓴 지표 샘플링 조건을 남긴다(2026-09-09).
|
||||||
|
|
||||||
|
왜 — 관을 나중에 놓으면 그 측점이 안 생기는데(계획서 3-14), 나중에 만들려면 **그때와 같은
|
||||||
|
조건**으로 떠야 한다. 조건을 안 남기면 되짚을 길이 없어 **지어내야 하는 자리**가 된다.
|
||||||
|
"""
|
||||||
|
if not request.filter_key or not request.method:
|
||||||
|
return
|
||||||
|
path = sampling_snapshot_path(project_root)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
atomic_write_json(
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"filter_key": request.filter_key,
|
||||||
|
"method": request.method,
|
||||||
|
"smooth": bool(request.smooth),
|
||||||
|
"surface_model_id": request.surface_model_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_sampling_snapshot(project_root: Path) -> dict[str, Any] | None:
|
||||||
|
"""남겨 둔 샘플링 조건. 없으면 `None` — **지어내지 않는다**(노선 확정을 한 번 더 받는다)."""
|
||||||
|
path = sampling_snapshot_path(project_root)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if not data.get("filter_key") or not data.get("method"):
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
async def _append_irregular_cross_sections(
|
async def _append_irregular_cross_sections(
|
||||||
connection: aiomysql.Connection,
|
connection: aiomysql.Connection,
|
||||||
project_id: UUID,
|
project_id: UUID,
|
||||||
@@ -208,6 +252,7 @@ async def _append_irregular_cross_sections(
|
|||||||
"""확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용)."""
|
"""확정 시 비정규 측점의 횡단을 생성해 종단 파일에 병합한다(파일 기반, 비치명적 호출용)."""
|
||||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
project_root = Path(resolve_stored_project_path(stored_path))
|
project_root = Path(resolve_stored_project_path(stored_path))
|
||||||
|
save_sampling_snapshot(project_root, request)
|
||||||
stored_options = await get_latest_section_options(connection, project_id)
|
stored_options = await get_latest_section_options(connection, project_id)
|
||||||
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
|
crs_epsg = await get_surface_crs_epsg(connection, project_id, request.surface_model_id)
|
||||||
irregular_stations = await asyncio.to_thread(
|
irregular_stations = await asyncio.to_thread(
|
||||||
|
|||||||
@@ -459,7 +459,17 @@
|
|||||||
"abbr": "개거"
|
"abbr": "개거"
|
||||||
},
|
},
|
||||||
"drawing_views": ["plan", "profile", "cross_section", "quantity"],
|
"drawing_views": ["plan", "profile", "cross_section", "quantity"],
|
||||||
"options": []
|
"options": [
|
||||||
|
{
|
||||||
|
"key": "ditch_spec",
|
||||||
|
"label": "규격",
|
||||||
|
"input": "select",
|
||||||
|
"choices": ["콘크리트 개거 150×200", "L형수로 H=0.2"],
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type_id": "ditch_side",
|
"type_id": "ditch_side",
|
||||||
@@ -709,7 +719,7 @@
|
|||||||
"key": "form",
|
"key": "form",
|
||||||
"label": "형식",
|
"label": "형식",
|
||||||
"input": "select",
|
"input": "select",
|
||||||
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식"],
|
"choices": ["중력식", "반중력식", "캔틸레버식", "부벽식", "식생옹벽블럭"],
|
||||||
"default": null,
|
"default": null,
|
||||||
"required": true,
|
"required": true,
|
||||||
"phase": "detail"
|
"phase": "detail"
|
||||||
@@ -720,6 +730,24 @@
|
|||||||
"input": "select",
|
"input": "select",
|
||||||
"choices": ["자동(성토 쪽)", "좌", "우"],
|
"choices": ["자동(성토 쪽)", "좌", "우"],
|
||||||
"default": "자동(성토 쪽)"
|
"default": "자동(성토 쪽)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_diameter_mm",
|
||||||
|
"label": "물빼기 구멍 지름",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎜",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_area_m2",
|
||||||
|
"label": "물빼기 구멍 1개당 벽면적",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎡",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -838,6 +866,24 @@
|
|||||||
"input": "select",
|
"input": "select",
|
||||||
"choices": ["자동(성토 쪽)", "좌", "우"],
|
"choices": ["자동(성토 쪽)", "좌", "우"],
|
||||||
"default": "자동(성토 쪽)"
|
"default": "자동(성토 쪽)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_diameter_mm",
|
||||||
|
"label": "물빼기 구멍 지름",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎜",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_area_m2",
|
||||||
|
"label": "물빼기 구멍 1개당 벽면적",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎡",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -956,6 +1002,24 @@
|
|||||||
"input": "select",
|
"input": "select",
|
||||||
"choices": ["자동(성토 쪽)", "좌", "우"],
|
"choices": ["자동(성토 쪽)", "좌", "우"],
|
||||||
"default": "자동(성토 쪽)"
|
"default": "자동(성토 쪽)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_diameter_mm",
|
||||||
|
"label": "물빼기 구멍 지름",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎜",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_area_m2",
|
||||||
|
"label": "물빼기 구멍 1개당 벽면적",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎡",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1148,6 +1212,24 @@
|
|||||||
"input": "select",
|
"input": "select",
|
||||||
"choices": ["자동(성토 쪽)", "좌", "우"],
|
"choices": ["자동(성토 쪽)", "좌", "우"],
|
||||||
"default": "자동(성토 쪽)"
|
"default": "자동(성토 쪽)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_diameter_mm",
|
||||||
|
"label": "물빼기 구멍 지름",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎜",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_area_m2",
|
||||||
|
"label": "물빼기 구멍 1개당 벽면적",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎡",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1238,6 +1320,33 @@
|
|||||||
"required": true,
|
"required": true,
|
||||||
"phase": "detail"
|
"phase": "detail"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "spillway",
|
||||||
|
"label": "방수로",
|
||||||
|
"input": "select",
|
||||||
|
"choices": ["있음", "없음"],
|
||||||
|
"default": null,
|
||||||
|
"required": true,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "stone_kind",
|
||||||
|
"label": "돌 종류",
|
||||||
|
"input": "select",
|
||||||
|
"choices": ["야면석·호박돌", "깬잡석", "깬돌", "견치돌"],
|
||||||
|
"default": null,
|
||||||
|
"required": true,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "back_len_cm",
|
||||||
|
"label": "뒷길이 ℓ3",
|
||||||
|
"input": "select",
|
||||||
|
"choices": ["25", "30", "35", "45", "55", "60", "75"],
|
||||||
|
"default": null,
|
||||||
|
"required": true,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "stone_supply",
|
"key": "stone_supply",
|
||||||
"label": "조달",
|
"label": "조달",
|
||||||
@@ -1313,6 +1422,24 @@
|
|||||||
"input": "number",
|
"input": "number",
|
||||||
"unit": "m",
|
"unit": "m",
|
||||||
"default": 2.0
|
"default": 2.0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_diameter_mm",
|
||||||
|
"label": "물빼기 구멍 지름",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎜",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_area_m2",
|
||||||
|
"label": "물빼기 구멍 1개당 벽면적",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎡",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1576,6 +1703,24 @@
|
|||||||
"default": 5,
|
"default": 5,
|
||||||
"required": false,
|
"required": false,
|
||||||
"phase": "b05"
|
"phase": "b05"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_diameter_mm",
|
||||||
|
"label": "물빼기 구멍 지름",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎜",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "weep_hole_area_m2",
|
||||||
|
"label": "물빼기 구멍 1개당 벽면적",
|
||||||
|
"input": "number",
|
||||||
|
"unit": "㎡",
|
||||||
|
"default": null,
|
||||||
|
"required": false,
|
||||||
|
"phase": "detail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -431,6 +431,24 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
|
|||||||
if not sets:
|
if not sets:
|
||||||
return 0
|
return 0
|
||||||
attached = 0
|
attached = 0
|
||||||
|
# ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
|
||||||
|
# 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다
|
||||||
|
# (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 관 440.241 은 **측점 440.0** 위에 선다.
|
||||||
|
# ⇒ 0.02m 로만 보면 그런 관은 **어느 측점에도 안 붙어** 횡단도에 안 서고 길이도 안 실려
|
||||||
|
# B08 이 「연장 없음」으로 막는다(실측: 배수관 넷이 그렇게 금액에서 빠져 있었다).
|
||||||
|
# ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다. 하나만 고르므로
|
||||||
|
# 두 번 세지 않고, 스냅 폭이 바뀌어도 따라간다.
|
||||||
|
owner: dict[float, float] = {}
|
||||||
|
for pipe_chainage in sets:
|
||||||
|
nearest = None
|
||||||
|
for section in cross_sections:
|
||||||
|
value = _number(section.get("chainage_m"), None)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if nearest is None or abs(value - pipe_chainage) < abs(nearest - pipe_chainage):
|
||||||
|
nearest = value
|
||||||
|
if nearest is not None:
|
||||||
|
owner[pipe_chainage] = nearest
|
||||||
for section in cross_sections:
|
for section in cross_sections:
|
||||||
chainage = _number(section.get("chainage_m"), None)
|
chainage = _number(section.get("chainage_m"), None)
|
||||||
if chainage is None:
|
if chainage is None:
|
||||||
@@ -440,7 +458,7 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
|
|||||||
reach = _CHAINAGE_TOLERANCE_M
|
reach = _CHAINAGE_TOLERANCE_M
|
||||||
if spec.get("type") in _SPAN_LINKED_TYPES:
|
if spec.get("type") in _SPAN_LINKED_TYPES:
|
||||||
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
|
reach += (_number(spec.get("span_m"), 0.0) or 0.0) / 2
|
||||||
if abs(chainage - pipe_chainage) <= reach:
|
if abs(chainage - pipe_chainage) <= reach or owner.get(pipe_chainage) == chainage:
|
||||||
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
|
# 세월교·BOX암거·물넘이는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
|
||||||
kind = spec.get("type")
|
kind = spec.get("type")
|
||||||
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
|
# ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08). 세트는 폭의
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
"""구조물 측점이 빠진 관·시설을 **알리고, 눌러서 만든다** (계획서 3-14 ㉯).
|
||||||
|
|
||||||
|
무엇이 문제였나
|
||||||
|
측점을 만드는 자리는 **B05 노선 [확정] 한 곳뿐**이다. 관을 저장하는
|
||||||
|
`PUT /drainage/pipe-points` 는 관 파일과 유역만 쓰고 측점을 다시 만들지 않는다.
|
||||||
|
⇒ **관을 나중에 놓거나 옮기면 그 측점이 안 생긴다.** 그 관은 횡단도에도 안 서고
|
||||||
|
수량·금액에서 통째로 빠지는데 **아무 말도 안 나온다**(실측: 배수관 넷).
|
||||||
|
|
||||||
|
왜 이 방식인가 (세 갈래 중 ㉯)
|
||||||
|
㉮ 관 저장 뒤 바로 만들기 — 그 엔드포인트에 노선·지표면 인자가 없어 끌어와야 함
|
||||||
|
㉯ **알리고 [측점 만들기] 단추** — 누를 때만 돌아 비용이 적고 **왜 값이 없는지가 보임**
|
||||||
|
㉰ 그대로 두기 — 조용히 빠지는 것이 문제라 적어도 알림은 있어야 함
|
||||||
|
|
||||||
|
⚠ **지어내지 않는 것** — 지표 샘플링 조건(어느 DTM·어느 방법)이 없으면 만들지 않는다.
|
||||||
|
조건이 다르면 그 측점만 다른 지표에서 뽑혀 **옆 측점과 지반고가 어긋난다.** 조건은
|
||||||
|
노선 확정 때 남긴 `B06_Section/sampling.json` 에서 읽고, 없으면 사유를 내고 막는다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||||
|
from B05_Profile.B05_Profile_Repository import get_latest_route, get_surface_crs_epsg
|
||||||
|
from B05_Profile.B05_Profile_Router_Confirm import load_sampling_snapshot
|
||||||
|
from common_util.common_util_storage import resolve_stored_project_path
|
||||||
|
from config.config_db import run_with_connection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/projects", tags=["B06 Section"])
|
||||||
|
|
||||||
|
#: 「그 자리에 측점이 있다」고 볼 거리(m).
|
||||||
|
#: ⚠⚠ **0.5m 다 — 0.05m 가 아니다**(2026-09-09 실측으로 뒤집힌 자리).
|
||||||
|
#: 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
|
||||||
|
#: (`B05_Profile_Engine_Sections_Core` 의 파일명 가드). 그래서 관 440.241 은 측점 440.0
|
||||||
|
#: 위에 서고, 그 측점은 **구조물 이름표까지 달고 있다**(`structure`).
|
||||||
|
#: 0.05m 로 보면 그런 자리를 「측점 없음」으로 잘못 세어 **있는 측점을 또 만들라고 한다.**
|
||||||
|
#: 스냅 폭이 「정수 미터 반올림」이므로 최대 어긋남은 0.5m 다.
|
||||||
|
STATION_MATCH_TOLERANCE_M = 0.5
|
||||||
|
|
||||||
|
SNAPSHOT_MISSING = (
|
||||||
|
"지표 샘플링 조건을 찾을 수 없어 측점을 만들 수 없음 — 1단계(지표 확정)를 마친 뒤"
|
||||||
|
" 다시 눌러야 함. ⚠ 조건을 지어내면 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋남"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _sampling_conditions(project_id: UUID, project_root: Path) -> dict[str, Any] | None:
|
||||||
|
"""이 프로젝트가 쓰는 지표 샘플링 조건. 둘 다 **기록된 값**이고 지어내지 않는다.
|
||||||
|
|
||||||
|
① 노선 [확정] 때 남긴 `B06_Section/sampling.json` — **그때 실제로 쓴 조건**이라 1순위.
|
||||||
|
② 없으면 1단계(지표 확정) 저장값 — B06 화면 `context` 가 쓰는 그 값이라 같은 조건이다.
|
||||||
|
옛 프로젝트는 ①이 없으므로 이 길이 없으면 단추가 영영 안 돈다.
|
||||||
|
"""
|
||||||
|
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||||
|
|
||||||
|
snapshot = load_sampling_snapshot(project_root)
|
||||||
|
if snapshot is not None:
|
||||||
|
return snapshot
|
||||||
|
params = await run_with_connection(get_surface_confirmation_params, str(project_id))
|
||||||
|
if not params or not params.get("source_filter") or not params.get("method"):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"filter_key": params["source_filter"],
|
||||||
|
"method": params["method"],
|
||||||
|
"smooth": bool(params.get("smooth")),
|
||||||
|
"surface_model_id": None,
|
||||||
|
"source": "stage1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_marks(project_root: Path, route_data_path: str) -> list[dict[str, Any]]:
|
||||||
|
"""구조물 측점 가운데 **종단 정본에 행이 없는 것**만. 없으면 빈 목록."""
|
||||||
|
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||||
|
_load_pipe_points,
|
||||||
|
_load_route_polyline,
|
||||||
|
resolve_extra_stations,
|
||||||
|
)
|
||||||
|
|
||||||
|
polyline = _load_route_polyline(project_root, route_data_path)
|
||||||
|
pipes = _load_pipe_points(project_root, polyline)
|
||||||
|
extras = resolve_extra_stations(project_root, pipes)
|
||||||
|
if not extras:
|
||||||
|
return []
|
||||||
|
existing = _station_chainages(project_root)
|
||||||
|
missing = []
|
||||||
|
for chainage, label in extras:
|
||||||
|
value = float(chainage)
|
||||||
|
if any(abs(value - other) <= STATION_MATCH_TOLERANCE_M for other in existing):
|
||||||
|
continue
|
||||||
|
missing.append({"chainage_m": round(value, 3), "label": label})
|
||||||
|
return sorted(missing, key=lambda item: item["chainage_m"])
|
||||||
|
|
||||||
|
|
||||||
|
def _station_chainages(project_root: Path) -> list[float]:
|
||||||
|
"""종단 정본에 실제로 서 있는 측점 누가거리. 파일이 없으면 빈 목록."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
folder = project_root / "B06_Section" / "longitudinal"
|
||||||
|
values: list[float] = []
|
||||||
|
for path in sorted(folder.glob("*.json")):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
for station in data.get("stations") or []:
|
||||||
|
chainage = station.get("chainage_m")
|
||||||
|
if isinstance(chainage, (int, float)):
|
||||||
|
values.append(float(chainage))
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None:
|
||||||
|
async def _load(connection):
|
||||||
|
stored = await get_project_storage_relative_path(connection, project_id)
|
||||||
|
route = await get_latest_route(connection, project_id)
|
||||||
|
return stored, route
|
||||||
|
|
||||||
|
stored, route = await run_with_connection(_load)
|
||||||
|
if not stored or not route:
|
||||||
|
return None
|
||||||
|
return Path(resolve_stored_project_path(stored)), route
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/section/missing-stations")
|
||||||
|
async def get_missing_stations(project_id: UUID) -> JSONResponse:
|
||||||
|
"""측점이 없는 구조물 목록 — 화면이 「측점 없는 관 N개」를 띄우는 데 쓴다."""
|
||||||
|
try:
|
||||||
|
paths = await _project_paths(project_id)
|
||||||
|
if paths is None:
|
||||||
|
return JSONResponse(content={"status": "success", "missing": [], "can_create": False})
|
||||||
|
project_root, route = paths
|
||||||
|
missing = await asyncio.to_thread(
|
||||||
|
_missing_marks, project_root, str(route["route_data_path"])
|
||||||
|
)
|
||||||
|
snapshot = await _sampling_conditions(project_id, project_root)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("B06 측점 점검 실패: project_id=%s", project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": "측점을 점검하지 못했습니다."},
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"status": "success",
|
||||||
|
"missing": missing,
|
||||||
|
"can_create": bool(missing) and snapshot is not None,
|
||||||
|
"reason": "" if snapshot is not None else SNAPSHOT_MISSING,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/section/missing-stations")
|
||||||
|
async def create_missing_stations(project_id: UUID) -> JSONResponse:
|
||||||
|
"""빠진 구조물 측점을 **노선 확정 때와 같은 조건으로** 만들어 종단 정본에 병합한다.
|
||||||
|
|
||||||
|
⚠ 구조물 측점 전체를 다시 뜬다 — 종단 병합이 비정규 측점을 **통째로 교체**하므로
|
||||||
|
빠진 것만 넘기면 이미 있던 구조물 측점이 지워진다.
|
||||||
|
"""
|
||||||
|
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||||
|
_load_pipe_points,
|
||||||
|
_load_route_polyline,
|
||||||
|
generate_irregular_sections,
|
||||||
|
resolve_extra_stations,
|
||||||
|
)
|
||||||
|
from B05_Profile.B05_Profile_Router_Confirm import (
|
||||||
|
_merge_irregular_into_longitudinal,
|
||||||
|
_section_options_from_stored,
|
||||||
|
)
|
||||||
|
from B06_Section.B06_Section_Repository import (
|
||||||
|
get_latest_section_options,
|
||||||
|
get_longitudinal_section,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
paths = await _project_paths(project_id)
|
||||||
|
if paths is None:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "확정된 노선이 없습니다."},
|
||||||
|
)
|
||||||
|
project_root, route = paths
|
||||||
|
snapshot = await _sampling_conditions(project_id, project_root)
|
||||||
|
if snapshot is None:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=409,
|
||||||
|
content={"status": "error", "message": SNAPSHOT_MISSING},
|
||||||
|
)
|
||||||
|
missing = await asyncio.to_thread(
|
||||||
|
_missing_marks, project_root, str(route["route_data_path"])
|
||||||
|
)
|
||||||
|
if not missing:
|
||||||
|
return JSONResponse(content={"status": "success", "created": 0, "missing": []})
|
||||||
|
|
||||||
|
async def _load(connection):
|
||||||
|
options = await get_latest_section_options(connection, project_id)
|
||||||
|
crs_epsg = await get_surface_crs_epsg(
|
||||||
|
connection, project_id, snapshot.get("surface_model_id")
|
||||||
|
)
|
||||||
|
longitudinal = await get_longitudinal_section(connection, project_id, route["id"])
|
||||||
|
return options, crs_epsg, longitudinal
|
||||||
|
|
||||||
|
stored_options, crs_epsg, longitudinal = await run_with_connection(_load)
|
||||||
|
|
||||||
|
def _regenerate() -> int:
|
||||||
|
polyline = _load_route_polyline(project_root, str(route["route_data_path"]))
|
||||||
|
pipes = _load_pipe_points(project_root, polyline)
|
||||||
|
extras = resolve_extra_stations(project_root, pipes)
|
||||||
|
stations = generate_irregular_sections(
|
||||||
|
project_root,
|
||||||
|
str(route["route_data_path"]),
|
||||||
|
str(snapshot["filter_key"]),
|
||||||
|
str(snapshot["method"]),
|
||||||
|
bool(snapshot.get("smooth")),
|
||||||
|
extra_stations=extras,
|
||||||
|
options=_section_options_from_stored(stored_options),
|
||||||
|
crs=f"EPSG:{crs_epsg}" if crs_epsg is not None else None,
|
||||||
|
)
|
||||||
|
if stations and longitudinal:
|
||||||
|
_merge_irregular_into_longitudinal(
|
||||||
|
project_root, str(longitudinal["longitudinal_file_path"]), stations
|
||||||
|
)
|
||||||
|
return len(stations)
|
||||||
|
|
||||||
|
made = await asyncio.to_thread(_regenerate)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("B06 측점 만들기 실패: project_id=%s", project_id)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": "측점을 만들지 못했습니다."},
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"status": "success",
|
||||||
|
# 새로 선 것만 세어 낸다 — 다시 뜬 총수(`made`)와 다르다.
|
||||||
|
"created": len(missing),
|
||||||
|
"regenerated": made,
|
||||||
|
"missing": missing,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -61,6 +61,27 @@ const inletStructure: InletStructureControl = {
|
|||||||
resetAdjust: () => undefined,
|
resetAdjust: () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그 관의 **주인 측점** 누가거리 — 관 자리에서 가장 가까운 측점 하나. 관 자리를 모르면 `null`.
|
||||||
|
*
|
||||||
|
* ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고
|
||||||
|
* **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다.
|
||||||
|
*/
|
||||||
|
function pipeOwnerChainage(
|
||||||
|
section: CrossSection,
|
||||||
|
sections: readonly CrossSection[],
|
||||||
|
): number | null {
|
||||||
|
const target = section.culvert?.chainage_m;
|
||||||
|
if (typeof target !== "number") return null;
|
||||||
|
let best: number | null = null;
|
||||||
|
for (const item of sections) {
|
||||||
|
if (best === null || Math.abs(item.chainage_m - target) < Math.abs(best - target)) {
|
||||||
|
best = item.chainage_m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
|
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
|
||||||
export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) {
|
export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) {
|
||||||
const design = section.design;
|
const design = section.design;
|
||||||
@@ -136,10 +157,20 @@ function areaRowOf(
|
|||||||
// ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만
|
// ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만
|
||||||
// (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번**
|
// (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번**
|
||||||
// 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다.
|
// 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다.
|
||||||
|
//
|
||||||
|
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
|
||||||
|
// 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
|
||||||
|
// (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을
|
||||||
|
// 덮어쓰는 것을 막는 가드). 그래서 관 440.241 은 **측점 440.0** 위에 선다.
|
||||||
|
// ⇒ 0.02m 로 주인을 가리면 **그런 관은 주인이 없어** 길이가 아무 데도 안 실리고,
|
||||||
|
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
|
||||||
|
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
|
||||||
|
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
|
||||||
|
const ownerChainage = pipeOwnerChainage(section, sections);
|
||||||
const pipeOwner =
|
const pipeOwner =
|
||||||
!!section.culvert &&
|
!!section.culvert &&
|
||||||
(typeof section.culvert.chainage_m !== "number" ||
|
(ownerChainage === null ||
|
||||||
Math.abs(section.culvert.chainage_m - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
|
Math.abs(ownerChainage - section.chainage_m) <= CHAINAGE_TOLERANCE_M);
|
||||||
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
|
const pipeLengthM = pipeOwner ? layouts.culvert?.pipe?.lengthM : undefined;
|
||||||
const pipeRow: Record<string, number> | null =
|
const pipeRow: Record<string, number> | null =
|
||||||
typeof pipeLengthM === "number" && pipeLengthM > 0
|
typeof pipeLengthM === "number" && pipeLengthM > 0
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B06_Section_UI_Missing_Stations.ts
|
||||||
|
* 「측점 없는 관 N개」 알림 + [측점 만들기] 단추 (계획서 3-14 ㉯).
|
||||||
|
*
|
||||||
|
* 무엇이 문제였나 — 측점을 만드는 자리는 **B05 노선 [확정] 한 곳뿐**이라, 관을 나중에
|
||||||
|
* 놓거나 옮기면 그 측점이 안 생긴다. 그 관은 횡단도에도 안 서고 **수량·금액에서 통째로
|
||||||
|
* 빠지는데 아무 말도 안 나온다**(실측: 배수관 넷이 B09 에서 막혀 있었다).
|
||||||
|
*
|
||||||
|
* ⚠ 자동으로 만들지 않는다 — 사용자가 누를 때만 돈다(비용이 큰 지표 샘플링이다).
|
||||||
|
* 대신 **왜 값이 없는지**가 화면에 남는다.
|
||||||
|
* ⚠ 지표 샘플링 조건이 저장에 없으면 **단추를 잠그고 사유를 보인다** — 조건을 지어내면
|
||||||
|
* 그 측점만 다른 지표에서 뽑혀 옆 측점과 지반고가 어긋난다.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { API_BASE_URL } from "@config/config_frontend";
|
||||||
|
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||||
|
|
||||||
|
interface MissingStation {
|
||||||
|
chainage_m: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MissingResponse {
|
||||||
|
missing?: MissingStation[];
|
||||||
|
can_create?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
created?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call(projectId: string, method: "GET" | "POST"): Promise<MissingResponse> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/section/missing-stations`, {
|
||||||
|
method,
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
const payload = (await response.json()) as MissingResponse;
|
||||||
|
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빠진 측점이 있으면 알림 줄을 `host` 맨 앞에 얹는다. 없으면 아무것도 하지 않는다.
|
||||||
|
* `onCreated` 는 측점이 실제로 생긴 뒤에만 불린다(화면을 다시 읽는 자리).
|
||||||
|
*/
|
||||||
|
export async function mountMissingStationNotice(
|
||||||
|
host: HTMLElement,
|
||||||
|
projectId: string,
|
||||||
|
onCreated: () => void | Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
|
let data: MissingResponse;
|
||||||
|
try {
|
||||||
|
data = await call(projectId, "GET");
|
||||||
|
} catch {
|
||||||
|
return; // 점검이 안 되는 것으로 화면을 막지 않는다 — 이 줄은 덤이다.
|
||||||
|
}
|
||||||
|
const missing = data.missing ?? [];
|
||||||
|
if (!missing.length) return;
|
||||||
|
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.className = "b06-missing-stations";
|
||||||
|
const text = document.createElement("p");
|
||||||
|
text.className = "b06-missing-stations__text";
|
||||||
|
const where = missing
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((item) => `${item.chainage_m.toFixed(2)}m ${item.label}`)
|
||||||
|
.join(" · ");
|
||||||
|
text.textContent =
|
||||||
|
`측점이 없는 구조물 ${missing.length}개 — ${where}` +
|
||||||
|
(missing.length > 6 ? ` 외 ${missing.length - 6}개` : "") +
|
||||||
|
". 이 자리는 횡단도에도 안 서고 수량에서도 빠집니다.";
|
||||||
|
box.append(text);
|
||||||
|
|
||||||
|
if (data.can_create) {
|
||||||
|
const button = createButton({ label: "측점 만들기", variant: "filled" });
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = "만드는 중…";
|
||||||
|
try {
|
||||||
|
const result = await call(projectId, "POST");
|
||||||
|
showToast(`측점 ${result.created ?? 0}개를 만들었습니다.`, "success");
|
||||||
|
box.remove();
|
||||||
|
await onCreated();
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||||
|
showToast(`측점을 만들지 못했습니다.${detail}`, "error");
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = "측점 만들기";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
box.append(button);
|
||||||
|
} else if (data.reason) {
|
||||||
|
const reason = document.createElement("p");
|
||||||
|
reason.className = "b06-missing-stations__reason";
|
||||||
|
reason.textContent = data.reason;
|
||||||
|
box.append(reason);
|
||||||
|
}
|
||||||
|
host.prepend(box);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
|
|||||||
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
|
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
|
||||||
import { navigateTo } from "../A00_Common/router";
|
import { navigateTo } from "../A00_Common/router";
|
||||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||||
|
import { mountMissingStationNotice } from "./B06_Section_UI_Missing_Stations";
|
||||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||||
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
import { attachCollapsible } from "@ui/ui_template_collapsible";
|
||||||
@@ -750,6 +751,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
|||||||
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
|
if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0)
|
||||||
stationInterval = storedOptions.station_interval_m;
|
stationInterval = storedOptions.station_interval_m;
|
||||||
renderSectionDetail();
|
renderSectionDetail();
|
||||||
|
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
|
||||||
|
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
|
||||||
|
void mountMissingStationNotice(root, projectId, refreshDetailForStructures);
|
||||||
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
|
void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6)
|
||||||
updateActionState();
|
updateActionState();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -416,3 +416,29 @@
|
|||||||
.b06-chart__spoil-fill.is-unclosed {
|
.b06-chart__spoil-fill.is-unclosed {
|
||||||
stroke: #c0392b;
|
stroke: #c0392b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 「측점 없는 관 N개」 알림 — 조용히 빠지던 것을 드러내는 줄(계획서 3-14 ㉯). */
|
||||||
|
.b06-missing-stations {
|
||||||
|
align-items: center;
|
||||||
|
background: #fff8e6;
|
||||||
|
border: 1px solid #e0b872;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b06-missing-stations__text {
|
||||||
|
color: #7a5a12;
|
||||||
|
flex: 1 1 320px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.b06-missing-stations__reason {
|
||||||
|
color: #8a6a22;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,17 +46,24 @@ NOTE_SECTION_MISSING = (
|
|||||||
"[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다"
|
"[저장]으로는 안 풀립니다. 횡단설계에서 그 측점이 서야 합니다"
|
||||||
)
|
)
|
||||||
# ── 허용 오차 둘 — **묻는 것이 다르다.** 한 자리에 모아 둔다 ────────────────
|
# ── 허용 오차 둘 — **묻는 것이 다르다.** 한 자리에 모아 둔다 ────────────────
|
||||||
# ⚠ **맞추지 말 것.** 하나로 맞추면 둘 다 나빠진다(2026-09-09 세 창 확인).
|
# · `LENGTH_MATCH_TOLERANCE_M` — 「이 관의 **길이를 어디서 가져오나**」.
|
||||||
# · `LENGTH_MATCH_TOLERANCE_M` (0.5m) — 「이 관의 **길이를 어디서 가져오나**」.
|
# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다.
|
||||||
# 넉넉해도 된다. 값을 못 찾는 것보다 옆 측점 길이를 쓰는 편이 낫다.
|
# · `SECTION_MATCH_TOLERANCE_M` — 「이 측점에 **횡단 설계가 있나**」.
|
||||||
# · `SECTION_MATCH_TOLERANCE_M` (0.05m) — 「이 측점에 **횡단 설계가 있나**」.
|
#
|
||||||
# 좁아야 한다. 없는 설계를 「있다」고 말하면 사용자가 [저장]을 눌러 보고 헤맨다.
|
# ⚠⚠ **둘 다 0.5 다. 좁히지 말 것** — 앞서 「좁아야 한다」던 판단이 실측으로 뒤집혔다
|
||||||
# ⚠ 두 값의 차이는 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고
|
# (2026-09-09 랩탑 메인). 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로
|
||||||
|
# 스냅**한다(횡단 파일명이 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드).
|
||||||
|
# 그래서 관 440.241 의 횡단은 **측점 440.0** 이고 구조물 이름표까지 달고 있다 —
|
||||||
|
# **없는 것이 아니다.** 좁게 보면 「그 측점의 횡단 자체가 없습니다」라는 **거짓 사유**가
|
||||||
|
# 뜬다(반대 방향의 거짓). 스냅 폭이 「정수 미터 반올림」이라 최대 어긋남이 0.5m 이고,
|
||||||
|
# 그래서 길이 찾기와 **같은 값**이 된다 — 우연이 아니라 같은 까닭이다.
|
||||||
|
# ⚠ 값이 같아졌다고 **하나로 합치지 말 것.** 묻는 것이 둘이라 근거도 둘이고, 한쪽 근거가
|
||||||
|
# 바뀌면 한쪽만 움직여야 한다.
|
||||||
|
# ⚠ 두 값은 **증상**이지 병이 아니다. 병은 **관 자리에 측점이 없는 것**이고
|
||||||
# (측점을 만드는 자리는 B05 노선 [확정] 한 곳뿐 — 관을 나중에 놓거나 옮기면 안 생긴다,
|
# (측점을 만드는 자리는 B05 노선 [확정] 한 곳뿐 — 관을 나중에 놓거나 옮기면 안 생긴다,
|
||||||
# 계획서 3-14), 그것이 고쳐지면 두 값의 차이가 **아무 관도 안 건드린다**
|
# 계획서 3-14), 그것이 고쳐지면 이 값들이 **아무 관도 안 건드린다.**
|
||||||
# (자기 자리에 측점이 있는 관 다섯이 이미 그 상태다).
|
|
||||||
LENGTH_MATCH_TOLERANCE_M = 0.5
|
LENGTH_MATCH_TOLERANCE_M = 0.5
|
||||||
SECTION_MATCH_TOLERANCE_M = 0.05
|
SECTION_MATCH_TOLERANCE_M = 0.5
|
||||||
|
|
||||||
NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다"
|
NOTE_KIND_DEFAULT = "관종을 안 정해 기본값({kind})으로 섰습니다 — 정하면 공종이 갈립니다"
|
||||||
NOTE_KIND_UNKNOWN = "「{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다"
|
NOTE_KIND_UNKNOWN = "「{kind}」은(는) 아는 관종이 아니라 공종을 못 골랐습니다"
|
||||||
@@ -89,7 +96,7 @@ def _nearest(
|
|||||||
"""관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다.
|
"""관 측점과 단면 측점이 소수점에서 어긋날 수 있어 **가까운 것**을 본다.
|
||||||
|
|
||||||
⚠ 좁게 본다(기본 0.5m) — 넓히면 옆 측점의 길이를 물어 와 조용히 틀린다.
|
⚠ 좁게 본다(기본 0.5m) — 넓히면 옆 측점의 길이를 물어 와 조용히 틀린다.
|
||||||
⚠ 「횡단이 있나」를 재는 `SECTION_MATCH_TOLERANCE_M`(0.05m)와 **다른 물음**이다 —
|
⚠ 「횡단이 있나」를 재는 `SECTION_MATCH_TOLERANCE_M`(0.5m)와 **다른 물음**이다 —
|
||||||
위 주석을 볼 것. 맞추면 둘 다 나빠진다.
|
위 주석을 볼 것. 맞추면 둘 다 나빠진다.
|
||||||
"""
|
"""
|
||||||
if not lengths:
|
if not lengths:
|
||||||
@@ -141,10 +148,10 @@ def build_rows(
|
|||||||
kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind)
|
kind_note = NOTE_KIND_UNKNOWN.format(kind=stored_kind)
|
||||||
|
|
||||||
length = _nearest(lengths, chainage)
|
length = _nearest(lengths, chainage)
|
||||||
# ⚠ **관이 놓인 그 측점**이 있는지를 본다 — 옆 측점이 있는 것은 소용없다.
|
# ⚠ 관이 선 자리의 횡단이 있는지를 본다. **스냅을 셈에 넣는다**(2026-09-09) —
|
||||||
# 실측(2026-09-08 `5601e828`): 관 439.55 근처에 측점 440.0 만 있었고, 0.5m 로
|
# 관 440.241 의 횡단은 측점 440.0 이고 그것이 정상이다. 옛 주석은 0.05m 로 좁게
|
||||||
# 느슨히 보면 「횡단이 있다」로 읽혀 **「[저장]하면 풀린다」는 거짓 안내**가 떴다.
|
# 보라고 했으나, 그때는 **스냅 때문에 관이 측점에 안 붙던 것**을 「측점이 없다」로
|
||||||
# 길이는 B06 이 **관이 놓인 측점에만** 싣는다(2026-09-08 이웃 오염을 고친 뒤).
|
# 읽던 시절이라 판단이 뒤집혔다. 길이는 여전히 **주인 측점 하나에만** 실린다.
|
||||||
has_section = not sections or any(
|
has_section = not sections or any(
|
||||||
abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections
|
abs(x - chainage) <= SECTION_MATCH_TOLERANCE_M for x in sections
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,6 +86,81 @@ STONE_WEIGHT_BASIS = (
|
|||||||
" 품셈·교본에는 돌중량표가 없음"
|
" 품셈·교본에는 돌중량표가 없음"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: ⭐ 사용자 확정 5차 큰 것 7 — **돌 무게는 계산식**: `뒷길이 × 0.77 × 2.65`.
|
||||||
|
#: 정본 여섯 탭이 모두 그 식을 그대로 적어 둔다(「2.09 × 0.45 × 0.77 × 2.65 ton/m3」).
|
||||||
|
#: ⚠ **야면석은 그 식이 안 맞는다** — 공극이 커 ㎥당 무게가 달라 원문이 값을 직접 준다
|
||||||
|
#: (35→0.575 · 45→0.880 · 55→1.100 ton/㎡). 그래서 **종류로 갈래를 둔다.**
|
||||||
|
#: ⚠ **잠정이다** — 확정 문구가 「나중에 실무자 협의 후 명확히」이므로 화면 근거에 적는다.
|
||||||
|
STONE_PACKING_RATIO = 0.77 # 채움률 — 돌 사이 공극을 뺀 몫
|
||||||
|
STONE_DENSITY_TON_M3 = 2.65 # 석재 비중
|
||||||
|
#: 계산식이 안 맞아 **관측표**를 쓰는 종류. 안 고른 경우도 이쪽(지금까지 쓰던 값이 그것).
|
||||||
|
STONE_WEIGHT_OBSERVED_KINDS = frozenset({"야면석·호박돌", "야면석"})
|
||||||
|
STONE_WEIGHT_FORMULA_BASIS = (
|
||||||
|
"⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: 물구멍 칸 — 2026-09-09 에 등록부에 생겼다(랩탑 메인 `0b0763ed`). **빈 칸이 기본**이다.
|
||||||
|
#: ⚠ 기본 Ø50 은 **국가기준**이고 2㎡당 1개소는 실무 관측값이다(법은 「2~3㎡당 1개소 이상」).
|
||||||
|
WEEP_HOLE_DIAMETER_KEY = "weep_hole_diameter_mm"
|
||||||
|
WEEP_HOLE_AREA_KEY = "weep_hole_area_m2"
|
||||||
|
WEEP_HOLE_DIAMETER_DEFAULT_MM = 50
|
||||||
|
|
||||||
|
|
||||||
|
def weep_hole_spec(options: dict[str, Any]) -> tuple[float, int, str]:
|
||||||
|
"""(개소당 벽면적 ㎡, 관 지름 ㎜, 근거). 안 정하면 기본으로 서고 그 사실이 근거에 남는다."""
|
||||||
|
area = _num(options.get(WEEP_HOLE_AREA_KEY), 0.0)
|
||||||
|
diameter = _num(options.get(WEEP_HOLE_DIAMETER_KEY), 0.0)
|
||||||
|
area_given = area > 0
|
||||||
|
dia_given = diameter > 0
|
||||||
|
if not area_given:
|
||||||
|
area = STONE_MASONRY["weep_hole_area_m2"]
|
||||||
|
if not dia_given:
|
||||||
|
diameter = WEEP_HOLE_DIAMETER_DEFAULT_MM
|
||||||
|
basis = (
|
||||||
|
f"{area:g}㎡당 1개소 · Ø{int(diameter)}"
|
||||||
|
+ ("" if area_given else " · ⚠ 면적 안 정함 — 실무 관측 2㎡(법은 2~3㎡당 1개소 이상)")
|
||||||
|
+ ("" if dia_given else " · ⚠ 지름 안 정함 — 국가기준 Ø50")
|
||||||
|
)
|
||||||
|
return area, int(diameter), basis
|
||||||
|
|
||||||
|
|
||||||
|
def stone_weight_per_m2(
|
||||||
|
back_cm: int, kind: str, observed: float | None
|
||||||
|
) -> tuple[str, float | None, str, str]:
|
||||||
|
"""(줄 이름, ton/㎡, 근거, source) — ⭐ 확정 5차 큰 것 7.
|
||||||
|
|
||||||
|
**계산식이 기본**이고 야면석만 관측표다. 돌쌓기·골막이가 같은 규칙을 쓰도록 여기 한 벌만 둔다.
|
||||||
|
"""
|
||||||
|
if kind in STONE_WEIGHT_OBSERVED_KINDS:
|
||||||
|
basis = (
|
||||||
|
f"× {observed} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}"
|
||||||
|
if observed is not None
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
return kind, observed, basis, STONE_WEIGHT_SOURCE
|
||||||
|
ton = back_cm / 100.0 * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3
|
||||||
|
basis = (
|
||||||
|
f"× {ton:.3f} ton/㎡ = 뒷길이 {back_cm / 100.0:g} × {STONE_PACKING_RATIO}"
|
||||||
|
f" × {STONE_DENSITY_TON_M3}"
|
||||||
|
+ (f" · {kind}" if kind else " · ⚠ 돌 종류를 안 골랐음")
|
||||||
|
+ f" · {STONE_WEIGHT_FORMULA_BASIS}"
|
||||||
|
)
|
||||||
|
return kind or "돌", ton, basis, ""
|
||||||
|
|
||||||
|
|
||||||
|
#: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」.
|
||||||
|
#: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면
|
||||||
|
#: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다.
|
||||||
|
#: 정본 `04.구조도(기슭막이).xls` **여섯 탭 전부** 상 0.30 · 하 0.45 로 같다
|
||||||
|
#: (H=1.0~3.0 · 메/찰 · 기초유/버림 전수 확인, 2026-09-09).
|
||||||
|
#: ⓘ 소광리는 같은 식에 **0.30 / 0.60** 을 쓴다(그래서 H=2.0 에서 0.900). 문서마다 폭이
|
||||||
|
#: 다르므로 **값이 적힌 구조물은 그 값**을 쓰고, 없으면 이 정본 값으로 서되 그 사실을 알린다.
|
||||||
|
STONE_BACKFILL_WIDTH_M = {"top": 0.30, "bottom": 0.45}
|
||||||
|
|
||||||
|
#: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다.
|
||||||
|
#: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로.
|
||||||
|
STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)"
|
||||||
|
|
||||||
|
|
||||||
#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3.
|
#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3.
|
||||||
#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다.
|
#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다.
|
||||||
@@ -177,8 +252,7 @@ def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str,
|
|||||||
#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을
|
#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을
|
||||||
#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어
|
#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어
|
||||||
#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는**
|
#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는**
|
||||||
#: 결함이 조용히 남는다.
|
#: 결함이 조용히 남는다.#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.**
|
||||||
#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.**
|
|
||||||
#: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) ·
|
#: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) ·
|
||||||
#: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**.
|
#: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**.
|
||||||
STONE_MASONRY = {
|
STONE_MASONRY = {
|
||||||
@@ -315,8 +389,16 @@ DESTINATION = {
|
|||||||
# ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에
|
# ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에
|
||||||
# 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에
|
# 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에
|
||||||
# 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다.
|
# 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다.
|
||||||
# ⚠ 「석적」은 정본에 없다(소광리 시트에만) — **안 낸다.**
|
# ⚠ 「석적」은 정본에 없고 소광리 시트에만 있으나 **확정 5차로 세운다** — 같은
|
||||||
|
# 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다.
|
||||||
"입적": "reference",
|
"입적": "reference",
|
||||||
|
"석적": "reference",
|
||||||
|
# 돌 종류를 고르면 그 이름으로 줄이 선다 — 자재총괄이 이름으로 찾으므로 넷 다 둔다.
|
||||||
|
"야면석·호박돌": "material",
|
||||||
|
"깬잡석": "material",
|
||||||
|
"견치돌": "material",
|
||||||
|
# 종류를 안 고른 경우의 이름 — 정본 계산표 줄 이름 그대로다(「돌 ℓ3=45cm」).
|
||||||
|
"돌": "material",
|
||||||
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
|
# 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다.
|
||||||
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
|
# 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다.
|
||||||
RUBBLE_BASE_NAME: "unit_price",
|
RUBBLE_BASE_NAME: "unit_price",
|
||||||
@@ -757,12 +839,9 @@ def stone_masonry(
|
|||||||
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
|
for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"):
|
||||||
if picked.get(key) is not None:
|
if picked.get(key) is not None:
|
||||||
table[key] = picked[key]
|
table[key] = picked[key]
|
||||||
#: ⚠ **표는 「뒤채움 몫」, 우리 식은 「빼는 몫」** — 뜻이 반대라 1 에서 뺀다.
|
# ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** —
|
||||||
#: 교본은 「뒤채움 = 뒷길이 × (깬돌·잡석 1/2, 야면석 1/3)」이고, 우리 식이 입적에서
|
# ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로
|
||||||
#: 빼는 것은 **돌 몸통**이라 `1 − 뒤채움몫` 이다. 종전 2/3 이 곧 야면석(1 − 1/3)이었다.
|
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
|
||||||
#: ⚠ 그대로 넣었더니 미지정 값이 15.130 → 19.045 로 바뀌었다(만들다 잡음).
|
|
||||||
backfill_share = picked.get("backfill_ratio")
|
|
||||||
body_ratio = 1.0 - float(backfill_share) if backfill_share is not None else 2.0 / 3.0
|
|
||||||
kind_label = str(picked.get("kind") or "")
|
kind_label = str(picked.get("kind") or "")
|
||||||
# ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도
|
# ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도
|
||||||
# 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이
|
# 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이
|
||||||
@@ -781,7 +860,8 @@ def stone_masonry(
|
|||||||
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
|
# 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04).
|
||||||
masonry_area = face_area * math.hypot(1.0, slope_ratio)
|
masonry_area = face_area * math.hypot(1.0, slope_ratio)
|
||||||
# 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.**
|
# 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.**
|
||||||
top_thickness = back_cm / 100.0 + constants["thickness_top_add_m"]
|
back_m = back_cm / 100.0
|
||||||
|
top_thickness = back_m + constants["thickness_top_add_m"]
|
||||||
bottom_thickness = top_thickness + constants["thickness_slope_per_m"] * max(
|
bottom_thickness = top_thickness + constants["thickness_slope_per_m"] * max(
|
||||||
height_m - constants["thickness_height_base_m"], 0.0
|
height_m - constants["thickness_height_base_m"], 0.0
|
||||||
)
|
)
|
||||||
@@ -831,38 +911,65 @@ def stone_masonry(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
stone_ton = table["stone_ton_per_m2"]
|
# 석적 — ⭐ 확정 5차 작은 것 4. 「체적」은 위 **입적** 줄이 이미 그것이고, 이쪽만 새 줄이다.
|
||||||
|
stone_pile = face_area * back_m * STONE_PACKING_RATIO
|
||||||
|
components.append(
|
||||||
|
Component(
|
||||||
|
"석적",
|
||||||
|
"㎥",
|
||||||
|
stone_pile,
|
||||||
|
DESTINATION["석적"],
|
||||||
|
f"정면적 × 뒷길이 {back_m:g}m × {STONE_PACKING_RATIO}(채움률) · {STONE_PILE_BASIS}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌).
|
||||||
|
# ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고,
|
||||||
|
# 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다).
|
||||||
|
stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2(
|
||||||
|
back_cm, kind_label, table["stone_ton_per_m2"]
|
||||||
|
)
|
||||||
|
weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else ""
|
||||||
|
if not kind_label:
|
||||||
|
notes.append(
|
||||||
|
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
|
||||||
|
"야면석이면 계산식이 안 맞아 관측표로 갈립니다"
|
||||||
|
)
|
||||||
if stone_ton is None:
|
if stone_ton is None:
|
||||||
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
|
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
|
||||||
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 야면석을 내지 못함")
|
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함")
|
||||||
else:
|
else:
|
||||||
components.append(
|
components.append(
|
||||||
Component(
|
Component(
|
||||||
"야면석",
|
stone_name,
|
||||||
"ton",
|
"ton",
|
||||||
masonry_area * stone_ton,
|
masonry_area * stone_ton,
|
||||||
DESTINATION["야면석"],
|
DESTINATION.get(stone_name, "material"),
|
||||||
f"돌쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}",
|
weight_basis,
|
||||||
source=STONE_WEIGHT_SOURCE,
|
source=weight_source,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 막자갈 = 입적 − (면적 × 뒷길이 × 뒤채움몫 + 고임돌).
|
# 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가
|
||||||
# 뒤채움 몫은 **돌 종류로 갈린다** — 깬돌·잡석 1/2 · 야면석 1/3 (교본 7-3).
|
# 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다.
|
||||||
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 — 막자갈에서 안 뺌
|
backfill_top = STONE_BACKFILL_WIDTH_M["top"]
|
||||||
stone_body = masonry_area * (back_cm / 100.0) * body_ratio # 면석 몸통 체적
|
backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"]
|
||||||
rubble = volume - (stone_body + wedge)
|
rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m
|
||||||
if rubble > 0:
|
components.append(
|
||||||
components.append(
|
Component(
|
||||||
Component(
|
"막자갈",
|
||||||
"막자갈",
|
"㎥",
|
||||||
"㎥",
|
rubble,
|
||||||
rubble,
|
DESTINATION["막자갈"],
|
||||||
DESTINATION["막자갈"],
|
f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m"
|
||||||
f"입적 − (면적×뒷길이×{body_ratio:.4g} + 고임돌) · {thickness_basis}"
|
f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값",
|
||||||
+ (f" · {kind_label}" if kind_label else ""),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
notes.append(
|
||||||
|
f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · "
|
||||||
|
f"하 {backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다"
|
||||||
|
"(소광리는 같은 식에 0.30/0.60 을 씁니다)"
|
||||||
|
)
|
||||||
|
|
||||||
if wet:
|
if wet:
|
||||||
mpa, mpa_basis = fill_concrete_mpa(options)
|
mpa, mpa_basis = fill_concrete_mpa(options)
|
||||||
@@ -898,22 +1005,29 @@ def stone_masonry(
|
|||||||
# 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인).
|
# 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인).
|
||||||
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
|
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
|
||||||
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
|
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
|
||||||
|
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
|
||||||
|
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
|
||||||
|
# ⓘ 정본은 개소당 관 길이를 **평균두께**로 잡는다(H=2.0 에서 0.83m). 우리는 상수 0.5m 라
|
||||||
|
# 그만큼 짧게 선다 — 관은 벽을 가로지르므로 정본 쪽이 이치에 맞는다(미결).
|
||||||
|
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
|
||||||
components.append(
|
components.append(
|
||||||
Component(
|
Component(
|
||||||
"물구멍관",
|
"물구멍관",
|
||||||
"m",
|
"m",
|
||||||
masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"],
|
masonry_area / hole_area * constants["weep_hole_length_m"],
|
||||||
DESTINATION["물구멍관"],
|
DESTINATION["물구멍관"],
|
||||||
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
|
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {constants['weep_hole_length_m']:g} m/개소"
|
||||||
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
|
f" · {hole_basis}",
|
||||||
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·"
|
spec=f"Ø{hole_dia}",
|
||||||
" 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
|
# 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.**
|
||||||
|
# ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고
|
||||||
|
# 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다.
|
||||||
if is_collected_stone(options):
|
if is_collected_stone(options):
|
||||||
collected = max(stone_body + wedge + max(rubble, 0.0), 0.0)
|
wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0
|
||||||
|
collected = max(stone_pile + wedge + rubble, 0.0)
|
||||||
if collected > 0:
|
if collected > 0:
|
||||||
components.append(
|
components.append(
|
||||||
Component(
|
Component(
|
||||||
@@ -921,9 +1035,8 @@ def stone_masonry(
|
|||||||
"㎥",
|
"㎥",
|
||||||
collected,
|
collected,
|
||||||
DESTINATION["채집석"],
|
DESTINATION["채집석"],
|
||||||
f"면석 몸통 {stone_body:.3f} + 고임돌 {wedge:.3f} + 막자갈"
|
f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f} ㎥"
|
||||||
f" {max(rubble, 0.0):.3f} ㎥ · 현장 채집분 ·"
|
" · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
|
||||||
" ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -969,11 +1082,13 @@ EXPANDERS = {
|
|||||||
# 이고 그 안의 계산이 같다. 갈래·사유는 `_UnitQuantity_Revetment` 가 든다(늦게 부른다 —
|
# 이고 그 안의 계산이 같다. 갈래·사유는 `_UnitQuantity_Revetment` 가 든다(늦게 부른다 —
|
||||||
# 그쪽이 이 모듈의 `stone_masonry` 를 쓰므로 위에서 부르면 맞물린다).
|
# 그쪽이 이 모듈의 `stone_masonry` 를 쓰므로 위에서 부르면 맞물린다).
|
||||||
"revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r),
|
"revetment": lambda h, l, o, f=None, r="": _revetment()(h, l, o, f, r),
|
||||||
# 골막이·바닥막이는 돌 계열이나 **돌쌓기 식을 그대로 못 쓴다** — 까닭은 그 모듈에 적었다.
|
# 골막이는 **개소당**이고 정면적이 사다리꼴이라 상장·하장·높이를 본다
|
||||||
"erosion_check": lambda h, l, o, f=None, r="": _stone_kin()("erosion_check"),
|
# (정본 「골막이(찰)(치수조서연결)」). 돌쌓기 식을 빌려 쓰지 않는다 — 까닭은 그 모듈에 적었다.
|
||||||
|
"erosion_check": lambda h, l, o, f=None, r="": _erosion_check()(h, o),
|
||||||
# 개거는 **m당** 원단위라 연장이 밑수다(정본 「개거(150-200)」·「L형수로-(201)」).
|
# 개거는 **m당** 원단위라 연장이 밑수다(정본 「개거(150-200)」·「L형수로-(201)」).
|
||||||
"open_ditch": lambda h, l, o, f=None, r="": _open_ditch()(l, o),
|
"open_ditch": lambda h, l, o, f=None, r="": _open_ditch()(l, o),
|
||||||
# 흙막이는 **아직 못 세움** — 정본에 떼흙막이 하나뿐인데 우리 형식 칸에 「떼」가 없음.
|
# 흙막이는 **「떼」만 섬**(정본 「떼흙막이」 개소당). 나머지 일곱 형식은 원단위 미확보.
|
||||||
|
# ⚠ 확정 4차 — 그림·옵션은 기슭막이와 한 벌이되 **수량 데이터는 분리**.
|
||||||
"soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o),
|
"soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o),
|
||||||
# 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」).
|
# 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」).
|
||||||
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o),
|
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o),
|
||||||
@@ -1008,11 +1123,11 @@ def _bed_sill():
|
|||||||
return bed_sill
|
return bed_sill
|
||||||
|
|
||||||
|
|
||||||
def _stone_kin():
|
def _erosion_check():
|
||||||
"""골막이·바닥막이 사유 함수 — 늦게 가져온다(서로 부르는 것을 푸는 자리)."""
|
"""골막이·바닥막이 사유 함수 — 늦게 가져온다(서로 부르는 것을 푸는 자리)."""
|
||||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import stone_kin_withheld
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import erosion_check_dam
|
||||||
|
|
||||||
return stone_kin_withheld
|
return erosion_check_dam
|
||||||
|
|
||||||
|
|
||||||
def _revetment():
|
def _revetment():
|
||||||
|
|||||||
@@ -19,9 +19,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, stone_masonry
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import Component, _num, stone_masonry
|
||||||
|
|
||||||
#: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다.
|
#: 형태 → 돌쌓기 갈래(찰이면 True). 여기 없는 형태는 전개식이 없다.
|
||||||
STONE_FORMS: dict[str, bool] = {
|
STONE_FORMS: dict[str, bool] = {
|
||||||
@@ -43,28 +45,6 @@ WITHHELD_FORMS: dict[str, str] = {
|
|||||||
"바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)",
|
"바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)",
|
||||||
}
|
}
|
||||||
|
|
||||||
#: 골막이·바닥막이 — **왜 아직 안 서는지**. 둘 다 돌 계열이나 돌쌓기 식을 그대로 못 쓴다.
|
|
||||||
#: ⚠ 근거 없는 식을 만들지 않는다(2026-09-09 확인). 칸·표가 오면 그때 붙인다.
|
|
||||||
#:
|
|
||||||
#: ⚠⚠ **골막이를 돌쌓기와 합치지 말 것** — 「같은 돌쌓기니 한 함수로」가 되기 쉬운 자리다.
|
|
||||||
#: 소광리 정본 「골막이(찰)(치수조서연결)」과 「돌기슭막이」를 나란히 재면 **넷이 다르다**:
|
|
||||||
#:
|
|
||||||
#: 두께식 골막이 (0.45+0.1H)/(0.45+0.4H) ↔ 기슭막이 뒷길이 기반(ℓ3+0.30 …)
|
|
||||||
#: 밑수 골막이 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만
|
|
||||||
#: 단위 골막이 **개소당** ↔ 기슭막이 m당 (확정 ⑦ 「통일 안 함」)
|
|
||||||
#: 정면적 골막이 **사다리꼴**(상장·하장) ↔ 기슭막이 직사각(H × 1)
|
|
||||||
#:
|
|
||||||
#: ⓘ 같은 것도 있다 — 막자갈 **2/3**·야면석 **0.88 ton/㎡**·물구멍 **2㎡/개소**·
|
|
||||||
#: 기울기 몫 **1.04**·고임돌 **0.15**·채움콘크리트 **0.2** 는 두 시트가 같은 값이다.
|
|
||||||
#: 우리 기본값이 어디서 왔는지의 답이기도 하다.
|
|
||||||
STONE_KIN_WITHHELD: dict[str, str] = {
|
|
||||||
"erosion_check": (
|
|
||||||
"골막이는 돌쌓기와 **두께식·밑수·단위가 다름**(소광리 정본 「골막이(찰)(치수조서연결)」 "
|
|
||||||
"실측) — 평균두께 {(0.45+0.1H)+(0.45+0.4H)}÷2 · 밑수 「돌쌓기+돌붙임」 · 개소당. "
|
|
||||||
"게다가 정면적이 사다리꼴이라 **상장·하장 칸이 있어야** 셈이 서는데 등록부에 없음"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
#: 돌붙임 원단위 — 소광리 정본 「돌붙임L3=45(사방)」(찰) · 「돌붙임L3=30(야면석메붙임)」(메).
|
#: 돌붙임 원단위 — 소광리 정본 「돌붙임L3=45(사방)」(찰) · 「돌붙임L3=30(야면석메붙임)」(메).
|
||||||
#: ⚠⚠ **돌쌓기 표(13-4)와 합치지 말 것.** 값이 겹쳐 보여 합치고 싶어지는 자리다:
|
#: ⚠⚠ **돌쌓기 표(13-4)와 합치지 말 것.** 값이 겹쳐 보여 합치고 싶어지는 자리다:
|
||||||
#:
|
#:
|
||||||
@@ -142,15 +122,263 @@ def revetment(
|
|||||||
return components, notes
|
return components, notes
|
||||||
|
|
||||||
|
|
||||||
def stone_kin_withheld(type_id: str) -> tuple[list[Component], list[str]]:
|
#: 돌골막이 원단위 — 소광리 정본 「골막이(찰)(치수조서연결)」, **개소당**.
|
||||||
"""골막이·바닥막이 — **식을 안 두는 까닭**만 낸다.
|
#: 「돌-골막이치수」 탭이 개소별 상장·하장·높이를 받아 **평균치수**를 내고, 이 표가 그 평균
|
||||||
|
#: 하나로 개소당 수량을 낸다. 우리는 개소마다 제 치수로 돌린다 — 밑수가 같으므로 식은 그대로다.
|
||||||
|
#:
|
||||||
|
#: ⚠⚠ **돌쌓기(기슭막이) 식과 합치지 말 것.** 넷이 다르다:
|
||||||
|
#:
|
||||||
|
#: 정면적 **사다리꼴** (상장+하장)÷2×H − 파형강관 ↔ 기슭막이 직사각 H×1
|
||||||
|
#: 두께식 (ℓ3+0.1H) / (ℓ3+0.4H) 의 평균 ↔ 기슭막이 ℓ3+0.30 / +0.30(H−1)
|
||||||
|
#: 밑수 **돌쌓기 + 돌붙임** ↔ 기슭막이 돌쌓기만
|
||||||
|
#: 단위 **개소당** ↔ 기슭막이 m당 (확정 ⑦ 「통일 안 함」)
|
||||||
|
#:
|
||||||
|
#: ⓘ 앞서 이 두께식을 「0.45 + 0.1H」로 적어 두었으나 **0.45 는 상수가 아니라 뒷길이 ℓ3**
|
||||||
|
#: 이다(정본 C26 이 `P6/100` 을 읽는다 — P6 = 45㎝). 두 식 모두 뒷길이 기반이고
|
||||||
|
#: **더하는 몫만 다르다.**
|
||||||
|
#: ⓘ 같은 것 — 막자갈 **2/3** · 야면석 **0.88 ton/㎡** · 물구멍 **2㎡/개소 × 0.5m** ·
|
||||||
|
#: 고임돌 **0.15** · 채움콘크리트 **0.2** 는 두 시트가 같은 값이라 표를 함께 쓴다.
|
||||||
|
#:
|
||||||
|
#: ⚠ **엑셀의 자름·반올림을 그대로 옮긴다** — 정본이 줄마다 `INT(x*100)/100` 으로 자르고
|
||||||
|
#: **다음 줄이 그 잘린 값을 받는다**(막자갈·잔토가 그렇다). 안 자르면 정본과 갈린다.
|
||||||
|
#:
|
||||||
|
#: ⚠ **방수로 파형강관을 늘 뺀다** — 정본이 상수 `(0.4 × 0.4) × 3.14` 로 박아 두었고
|
||||||
|
#: (Ø0.8 파형강관, 울진 관급 목록에 있는 규격) 방수로 치수가 0 인 개소에서도 뺀다.
|
||||||
|
#: **「방수로 없음」을 고를 칸이 등록부에 없어** 그대로 따른다 — 그 사실을 줄로 낸다.
|
||||||
|
EROSION_CHECK_DAM = {
|
||||||
|
"spillway_pipe_r_m": 0.4, # 정본 상수 — 0.4 × 0.4 × 3.14
|
||||||
|
"spillway_pipe_pi": 3.14,
|
||||||
|
"thickness_top_per_m": 0.1, # 상부 두께 = ℓ3 + 0.1H
|
||||||
|
"thickness_bottom_per_m": 0.4, # 하부 두께 = ℓ3 + 0.4H
|
||||||
|
"rubble_body_ratio": 2 / 3, # 막자갈에서 뺄 몸통 몫
|
||||||
|
"trench_extra_m": 0.3, # 바닥파기 폭 = 평균두께 + 0.3
|
||||||
|
"trench_depth_m": 0.5, # 바닥파기 깊이
|
||||||
|
"default_slope_ratio": 0.3, # 반수면 비탈 1:0.3 — 정본 붙박이
|
||||||
|
}
|
||||||
|
|
||||||
⚠ 돌 계열이라 `stone_masonry` 를 부르고 싶어지는 자리다. 실제로 부르면 **값이 나오므로**
|
#: 골막이가 세우는 형식. 정본 탭이 **찰쌓기 하나**뿐이다.
|
||||||
아무 시험도 안 잡는다 — 오늘 큰돌쌓기에서 같은 일이 있었다(직경 60~80㎝ 가 「뒷길이
|
EROSION_CHECK_FORMS: frozenset[str] = frozenset({"돌"})
|
||||||
45㎝」 계수로 돌던 자리). 그래서 **일부러 안 부른다.**
|
|
||||||
|
#: 나머지 형식과 **왜 없는지**. 지어내지 않는다.
|
||||||
|
EROSION_CHECK_WITHHELD: dict[str, str] = {
|
||||||
|
"돌망태": "돌망태는 품셈 13-8 이고 규격 축이 망태 치수라 돌쌓기 표를 못 씀 — 원단위 미확보",
|
||||||
|
"콘크리트": "콘크리트 골막이는 몸체 두께·저판이 정본에 없어 물량이 서지 않음",
|
||||||
|
"통나무": (
|
||||||
|
"통나무 골막이는 품셈 13-13(목재틀)이고 밑수가 ㎥당 목공 품이라 축이 다름 — 원단위 미확보"
|
||||||
|
),
|
||||||
|
"바자": "바자얽기(품셈 5-15)는 별도 일위대가를 만들어 잇기로 확정(2026-09-09 ⑧-4)",
|
||||||
|
"흙": "흙골막이는 정본에 탭이 없고 품셈 장도 다름 — 원단위 미확보",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 엑셀 자름·반올림 ────────────────────────────────────────────────────────
|
||||||
|
# ⚠⚠ **걷어 내지 말 것.** 「자릿수 맞추기」로 보여 지우고 싶어지는 자리인데, 정본이
|
||||||
|
# **줄마다 자르고 다음 줄이 그 잘린 값을 받는다** — 표시용이 아니라 **계산의 일부**다.
|
||||||
|
# 입적 = INT(정면적 × 평균두께) ← 평균두께가 이미 잘린 값(1.09)
|
||||||
|
# 막자갈 = INT(입적 − (…)) ← 입적이 이미 잘린 값(20.56)
|
||||||
|
# 잔토 = ROUND(바닥파기 − (…)) ← 바닥파기가 이미 잘린 값(8.61)
|
||||||
|
# 안 자르면 막자갈이 10.55, 잔토가 2.75 로 나와 **정본과 갈린다.**
|
||||||
|
# 「정본과 같은 값」의 뜻을 **소수점까지**로 잡은 것이고, 거울 시험이 그것을 잡는다.
|
||||||
|
|
||||||
|
|
||||||
|
def _floor2(value: float) -> float:
|
||||||
|
"""엑셀 `INT(x*100)/100` · `ROUNDDOWN(x,2)`."""
|
||||||
|
return math.floor(value * 100) / 100
|
||||||
|
|
||||||
|
|
||||||
|
def _round2(value: float) -> float:
|
||||||
|
"""엑셀 `ROUND(x,2)`.
|
||||||
|
|
||||||
|
⚠ 파이썬 `round` 는 **짝수 반올림**이라 4.905 를 4.90 으로 내린다(엑셀은 4.91).
|
||||||
|
물구멍관이 정확히 그 값이라 바꾸면 정본과 갈린다.
|
||||||
"""
|
"""
|
||||||
why = STONE_KIN_WITHHELD.get(type_id)
|
return float(Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
||||||
return [], [why] if why else []
|
|
||||||
|
|
||||||
|
def _ceil2(value: float) -> float:
|
||||||
|
"""엑셀 `ROUNDUP(x,2)` — 야면석만 이쪽이다(정본이 그 줄만 올림)."""
|
||||||
|
return math.ceil(value * 100) / 100
|
||||||
|
|
||||||
|
|
||||||
|
def erosion_check_dam(
|
||||||
|
height_m: float, options: dict[str, Any]
|
||||||
|
) -> tuple[list[Component], list[str]]:
|
||||||
|
"""돌골막이 1개소 전개 — 정본 「골막이(찰)(치수조서연결)」의 식을 그대로 돌린다."""
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import (
|
||||||
|
DESTINATION,
|
||||||
|
STONE_BACK_LENGTH_TABLE,
|
||||||
|
STONE_MASONRY,
|
||||||
|
_back_length,
|
||||||
|
fill_concrete_mpa,
|
||||||
|
stone_weight_per_m2,
|
||||||
|
weep_hole_spec,
|
||||||
|
)
|
||||||
|
|
||||||
|
form = str(options.get("form") or "").strip()
|
||||||
|
if form not in EROSION_CHECK_FORMS:
|
||||||
|
why = EROSION_CHECK_WITHHELD.get(form)
|
||||||
|
if why:
|
||||||
|
return [], [f"골막이 형식 「{form}」 — {why}"]
|
||||||
|
return [], [
|
||||||
|
f"골막이 형식이 정해지지 않았습니다(지금 「{form or '빈 값'}」) "
|
||||||
|
"— 형식을 골라야 물량이 섭니다"
|
||||||
|
]
|
||||||
|
|
||||||
|
top_m = _num(options.get("top_length_m"), 0.0)
|
||||||
|
bottom_m = _num(options.get("bottom_length_m"), 0.0)
|
||||||
|
if top_m <= 0 or bottom_m <= 0 or height_m <= 0:
|
||||||
|
return [], [
|
||||||
|
"골막이 정면적이 **사다리꼴**이라 상장ⓐ·하장ⓑ·높이가 다 있어야 셈이 섭니다 "
|
||||||
|
f"(지금 상장 {top_m:g}m · 하장 {bottom_m:g}m · 높이 {height_m:g}m)"
|
||||||
|
]
|
||||||
|
|
||||||
|
const = EROSION_CHECK_DAM
|
||||||
|
notes: list[str] = []
|
||||||
|
|
||||||
|
back_cm = _back_length(options)
|
||||||
|
coeff = STONE_BACK_LENGTH_TABLE.get(back_cm)
|
||||||
|
if coeff is None:
|
||||||
|
return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"]
|
||||||
|
if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")):
|
||||||
|
notes.append(
|
||||||
|
f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
|
||||||
|
"— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
|
||||||
|
)
|
||||||
|
back_m = back_cm / 100.0
|
||||||
|
|
||||||
|
given = _num(options.get("face_slope_ratio"), 0.0)
|
||||||
|
if given > 0:
|
||||||
|
slope, slope_note = given, f"사용자 지정 1:{given:g}"
|
||||||
|
else:
|
||||||
|
slope = const["default_slope_ratio"]
|
||||||
|
slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
|
||||||
|
|
||||||
|
# ① 정면적(사다리꼴) − ② 방수로 파형강관 단면
|
||||||
|
# ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.**
|
||||||
|
# ⓘ 정본은 방수로 치수가 0 인 개소에서도 뺐다. 그러므로 **정본과 같은 값은 「있음」**이고,
|
||||||
|
# 「없음」은 정본보다 크게 나온다. 안 고르면 정본 쪽(「있음」)으로 서고 그 사실을 알린다.
|
||||||
|
trapezoid = _floor2((top_m + bottom_m) / 2 * height_m)
|
||||||
|
spillway = str(options.get("spillway") or "").strip()
|
||||||
|
pipe = const["spillway_pipe_r_m"] ** 2 * const["spillway_pipe_pi"]
|
||||||
|
if spillway == "없음":
|
||||||
|
front_area = trapezoid
|
||||||
|
notes.append("방수로 「없음」이라 파형강관 단면을 안 뺐습니다 — 정본보다 정면적이 큽니다")
|
||||||
|
else:
|
||||||
|
front_area = trapezoid - pipe
|
||||||
|
notes.append(
|
||||||
|
f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8)"
|
||||||
|
+ ("" if spillway else " · ⚠ 방수로를 안 골라 정본대로 뺐습니다")
|
||||||
|
)
|
||||||
|
|
||||||
|
masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0)))
|
||||||
|
top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m)
|
||||||
|
thickness = _floor2(
|
||||||
|
(
|
||||||
|
(back_m + const["thickness_top_per_m"] * height_m)
|
||||||
|
+ (back_m + const["thickness_bottom_per_m"] * height_m)
|
||||||
|
)
|
||||||
|
/ 2
|
||||||
|
)
|
||||||
|
volume = _floor2(front_area * thickness)
|
||||||
|
facing = top_m * (top_t - back_m)
|
||||||
|
base_area = masonry + facing
|
||||||
|
|
||||||
|
rows: list[tuple[str, str, float, str]] = [
|
||||||
|
("돌쌓기", "㎡", masonry, f"정면적 {front_area:.4f}㎡ × √(1+n²) · 기울기 {slope_note}"),
|
||||||
|
("돌붙임", "㎡", facing, f"상장 {top_m:g}m × (상부두께 {top_t:g} − 뒷길이 {back_m:g})"),
|
||||||
|
(
|
||||||
|
"입적",
|
||||||
|
"㎥",
|
||||||
|
volume,
|
||||||
|
f"정면적 × 평균두께 {thickness:g}m "
|
||||||
|
f"[{{(ℓ3+0.1H)+(ℓ3+0.4H)}}÷2 · ℓ3={back_m:g} · H={height_m:g}]",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 돌 무게 — ⭐ 확정 5차 큰 것 7. 돌쌓기와 **같은 헬퍼**를 쓴다(규칙이 하나여야 한다).
|
||||||
|
kind = str(options.get("stone_kind") or "").strip()
|
||||||
|
stone_name, stone_ton, weight_tail, _src = stone_weight_per_m2(
|
||||||
|
back_cm, kind, coeff["stone_ton_per_m2"]
|
||||||
|
)
|
||||||
|
if stone_ton is None:
|
||||||
|
notes.append(f"뒷길이 {back_cm}㎝ 에 {stone_name} 중량 칸이 비어 있어 돌을 못 세웠습니다")
|
||||||
|
else:
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
stone_name,
|
||||||
|
"ton",
|
||||||
|
_ceil2(base_area * stone_ton),
|
||||||
|
f"(돌쌓기+돌붙임) {base_area:.4f}㎡ {weight_tail}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
wedge_per = coeff["wedge_stone_m3_per_m2"]
|
||||||
|
wedge = _round2(base_area * _num(wedge_per)) if wedge_per is not None else 0.0
|
||||||
|
if wedge_per is None:
|
||||||
|
notes.append(f"뒷길이 {back_cm}㎝ 에 고임돌 칸이 비어 있습니다 — 막자갈에서 안 뺐습니다")
|
||||||
|
else:
|
||||||
|
rows.append(("고임돌", "㎥", wedge, f"(돌쌓기+돌붙임) × {wedge_per} ㎥/㎡"))
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
"막자갈",
|
||||||
|
"㎥",
|
||||||
|
_floor2(volume - (base_area * back_m * const["rubble_body_ratio"] + wedge)),
|
||||||
|
"입적 − ((돌쌓기+돌붙임) × 뒷길이 × 2/3 + 고임돌)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
"물구멍관",
|
||||||
|
"m",
|
||||||
|
_round2(masonry / hole_area * STONE_MASONRY["weep_hole_length_m"]),
|
||||||
|
f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {STONE_MASONRY['weep_hole_length_m']:g}m/개소"
|
||||||
|
f" · {hole_basis} (Ø{hole_dia})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 바닥파기 — 비탈 사면장 두 쪽 + 하장 한 쪽, 폭은 평균두께 + 0.3.
|
||||||
|
slant = _floor2(math.hypot((top_m - bottom_m) / 2, height_m))
|
||||||
|
width = thickness + const["trench_extra_m"]
|
||||||
|
depth = const["trench_depth_m"]
|
||||||
|
trench = _floor2(slant * depth * width * 2 + bottom_m * depth * width)
|
||||||
|
spoil = _round2(trench - (slant * depth * width + bottom_m * depth * width))
|
||||||
|
rows.extend(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"터파기",
|
||||||
|
"㎥",
|
||||||
|
trench,
|
||||||
|
f"(사면장 {slant:g} × {depth:g} × {width:g}) × 2 + (하장 {bottom_m:g} × "
|
||||||
|
f"{depth:g} × {width:g}) · 정본 이름 「바닥파기」",
|
||||||
|
),
|
||||||
|
("잔토처리", "㎥", spoil, f"바닥파기 − (사면장 + 하장) × {depth:g} × {width:g}"),
|
||||||
|
("되메우기", "㎥", trench - spoil, "바닥파기 − 잔토"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
components = [
|
||||||
|
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
|
||||||
|
for name, unit, amount, basis in rows
|
||||||
|
]
|
||||||
|
mpa, mpa_basis = fill_concrete_mpa(options)
|
||||||
|
fill_per = coeff["fill_concrete_m3_per_m2"]
|
||||||
|
components.append(
|
||||||
|
Component(
|
||||||
|
"채움콘크리트",
|
||||||
|
"㎥",
|
||||||
|
_round2(base_area * _num(fill_per)),
|
||||||
|
DESTINATION["채움콘크리트"],
|
||||||
|
f"(돌쌓기+돌붙임) × {fill_per} ㎥/㎡ · {mpa_basis}",
|
||||||
|
spec=f"{mpa}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not kind:
|
||||||
|
notes.append(
|
||||||
|
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 "
|
||||||
|
"— 정본 탭은 야면석이고, 야면석은 계산식이 안 맞아 관측표로 갈립니다"
|
||||||
|
)
|
||||||
|
return components, notes
|
||||||
|
|
||||||
|
|
||||||
#: 개거(겉도랑) 원단위 — 소광리 정본 「개거(150-200) (2)」·「L형수로-(201)」, **둘 다 m당**.
|
#: 개거(겉도랑) 원단위 — 소광리 정본 「개거(150-200) (2)」·「L형수로-(201)」, **둘 다 m당**.
|
||||||
@@ -175,8 +403,8 @@ def stone_kin_withheld(type_id: str) -> tuple[list[Component], list[str]]:
|
|||||||
#: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** —
|
#: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** —
|
||||||
#: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게.
|
#: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게.
|
||||||
#:
|
#:
|
||||||
#: ⚠ **규격이 붙박이다** — 정본에 「150×200」 하나뿐이고 `open_ditch` 등록부에 **옵션이 하나도
|
#: ✅ **규격 칸이 생겼다**(2026-09-09 랩탑 메인 `0b0763ed`) — 두 규격을 고를 수 있고,
|
||||||
#: 없다**(규격 칸 없음). 칸이 생기면 이 표를 열면 된다.
|
#: 비우면 「콘크리트 개거 150×200」으로 선다.
|
||||||
OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
|
OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
|
||||||
"콘크리트 개거 150×200": {
|
"콘크리트 개거 150×200": {
|
||||||
"rows": (
|
"rows": (
|
||||||
@@ -208,10 +436,12 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
|
|||||||
|
|
||||||
#: 떼흙막이 원단위 — 소광리 정본 「떼흙막이」, **개소당**. 치수는 전부 **평균 붙박이**다
|
#: 떼흙막이 원단위 — 소광리 정본 「떼흙막이」, **개소당**. 치수는 전부 **평균 붙박이**다
|
||||||
#: (상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54 — 원문에 「(평균)」이라 적혀 있다).
|
#: (상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54 — 원문에 「(평균)」이라 적혀 있다).
|
||||||
#: ⚠⚠ **아직 못 쓴다** — 우리 `soil_guard` 형식이 「콘크리트 · 돌(찰) · 돌(메) · 블록쌓기 ·
|
#: ✅ **형식 칸에 「떼」가 들어와 이 표가 선다**(2026-09-09 랩탑 메인 `513aff3e`).
|
||||||
#: 돌망태」 다섯이고 **「떼」가 없다.** 형식 칸이 생기면 이 표를 그대로 붙이면 된다.
|
#: ⚠ 나머지 일곱 형식은 **정본에 탭이 없다.** 돌(찰)/(메)에 기슭막이 식을 빌려 쓰지 않는다 —
|
||||||
#: ⚠ 나머지 다섯 형식은 **정본에 탭이 없다.** 돌(찰)/(메)에 기슭막이 식을 빌려 쓰지 않는다 —
|
|
||||||
#: 흙막이는 교본상 앞면 1:0.3·뒷면 수직·천단 30㎝ 라 축이 다르고, 무엇보다 **근거가 없다.**
|
#: 흙막이는 교본상 앞면 1:0.3·뒷면 수직·천단 30㎝ 라 축이 다르고, 무엇보다 **근거가 없다.**
|
||||||
|
#: ⚠⚠ **사용자 확정 4차**(2026-09-09) — 「흙막이는 횡단도에서 표현방식·옵션이 기슭막이와
|
||||||
|
#: 동일, 형상도 동일. 다만 **데이터는 분리하여 계산**」. ⇒ **그림·옵션은 한 벌로 쓰되
|
||||||
|
#: 수량은 여기서 제 것으로 낸다.** 그림이 같다고 기슭막이 원단위를 끌어오면 그 확정을 어긴다.
|
||||||
SOIL_GUARD_SOD: dict[str, Any] = {
|
SOIL_GUARD_SOD: dict[str, Any] = {
|
||||||
"unit_label": "개소당",
|
"unit_label": "개소당",
|
||||||
"rows": (
|
"rows": (
|
||||||
@@ -222,21 +452,38 @@ SOIL_GUARD_SOD: dict[str, Any] = {
|
|||||||
"머리떼 1.5×0.2 + 바닥떼 1.1×0.2 + 정면떼 (1.5+1.1)÷2×0.5 "
|
"머리떼 1.5×0.2 + 바닥떼 1.1×0.2 + 정면떼 (1.5+1.1)÷2×0.5 "
|
||||||
"+ 양기슭바닥떼 0.54×0.2×2 · 규격 20×20㎝",
|
"+ 양기슭바닥떼 0.54×0.2×2 · 규격 20×20㎝",
|
||||||
),
|
),
|
||||||
("바닥파기", "㎥", 0.17, "(0.54×0.4×0.2×2) + (1.1×0.4×0.2)"),
|
# ⚠ 이름을 **터파기**로 낸다 — 정본 이름은 「바닥파기」이나 그대로 두면 토공 축에
|
||||||
|
# 안 실린다(`DESTINATION` 이 이름으로 가른다). 골막이도 같은 자리를 그렇게 냈다.
|
||||||
|
("터파기", "㎥", 0.17, "(0.54×0.4×0.2×2) + (1.1×0.4×0.2) · 정본 이름 「바닥파기」"),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
|
def soil_guard(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
|
||||||
"""흙막이 — **아직 못 세운다.** 어느 형식이 왜 안 되는지만 낸다."""
|
"""흙막이 전개 — **「떼」만 선다**(정본 「떼흙막이」, 개소당). 나머지는 사유만.
|
||||||
|
|
||||||
|
⚠ 치수가 **전부 평균 붙박이**다 — 정본 표에 「(평균)」이라 적혀 있고 개소별 치수조서가
|
||||||
|
따로 없다. 그래서 높이·길이를 받지 않는다. 개소별로 갈리게 하려면 치수 칸이 먼저다.
|
||||||
|
"""
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import DESTINATION
|
||||||
|
|
||||||
form = str(options.get("form") or "").strip()
|
form = str(options.get("form") or "").strip()
|
||||||
if not form:
|
if not form:
|
||||||
return [], ["흙막이 형식이 정해지지 않았습니다 — 형식을 골라야 물량이 섭니다"]
|
return [], ["흙막이 형식이 정해지지 않았습니다 — 형식을 골라야 물량이 섭니다"]
|
||||||
return [], [
|
if form != "떼":
|
||||||
f"흙막이 형태 「{form}」 — 정본(소광리 53탭)에 그 형식의 산출식이 없습니다. "
|
return [], [
|
||||||
"있는 것은 **떼흙막이** 하나인데 우리 형식 칸에 「떼」가 없습니다. "
|
f"흙막이 형식 「{form}」 — 정본(소광리 53탭)에 그 형식의 산출식이 없습니다"
|
||||||
"⚠ 돌(찰)·돌(메)에 기슭막이 식을 빌려 쓰지 않습니다 — 흙막이는 앞면 1:0.3·뒷면 수직·"
|
"(있는 것은 **떼흙막이** 하나). ⚠ 돌(찰)·돌(메)에 기슭막이 식을 빌려 쓰지 않습니다 "
|
||||||
"천단 30㎝(교본)라 축이 다르고 근거가 없습니다"
|
"— 흙막이는 앞면 1:0.3·뒷면 수직·천단 30㎝(교본)라 축이 다르고 근거가 없습니다"
|
||||||
|
]
|
||||||
|
|
||||||
|
components = [
|
||||||
|
Component(name, unit, amount, DESTINATION.get(name, "quantity"), basis)
|
||||||
|
for name, unit, amount, basis in SOIL_GUARD_SOD["rows"]
|
||||||
|
]
|
||||||
|
return components, [
|
||||||
|
f"떼흙막이는 **{SOIL_GUARD_SOD['unit_label']}**이고 치수가 정본 평균 붙박이입니다"
|
||||||
|
"(상단 1.5 · 하단 1.1 · 높이 0.5 · 두께 0.2 · 기슭 0.54m) — 개소 제원을 안 봅니다"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -267,8 +514,8 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
|
|||||||
for name, unit, per_m, basis in table["rows"]
|
for name, unit, per_m, basis in table["rows"]
|
||||||
]
|
]
|
||||||
notes: list[str] = []
|
notes: list[str] = []
|
||||||
if "ditch_spec" not in options:
|
if not options.get("ditch_spec"):
|
||||||
notes.append(f"규격이 「{spec}」 붙박이입니다 — 개거 제원에 규격 칸이 없습니다")
|
notes.append(f"규격을 안 골라 「{spec}」으로 섰습니다 — 제원에서 고를 수 있습니다")
|
||||||
if spec == "콘크리트 개거 150×200":
|
if spec == "콘크리트 개거 150×200":
|
||||||
notes.append(
|
notes.append(
|
||||||
"⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다"
|
"⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다"
|
||||||
|
|||||||
@@ -155,6 +155,14 @@ def _leaf_row(
|
|||||||
if item.application_ratio_pct is not None:
|
if item.application_ratio_pct is not None:
|
||||||
# ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다.
|
# ⚠ 곱하지 않는다 — B08 이 이미 곱한 값이다. 산출근거로만 적는다.
|
||||||
row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량"
|
row.note = f"반영률 {item.application_ratio_pct}% 적용 후 수량"
|
||||||
|
elif item.application_ratio_breakdown:
|
||||||
|
# ⚠ **「율 없음」이 아니라 「갈래마다 다름」이다.** 율이 갈리는 줄은 B08 이 `pct` 를
|
||||||
|
# 비우고 갈래로만 보낸다. 그 사실을 안 적으면 **값은 맞는데 왜 그 수량인지**를
|
||||||
|
# 사람이 못 본다 — 값이 맞아도 그것은 반쪽이다(2026-09-09 두 창 확인).
|
||||||
|
parts = ", ".join(
|
||||||
|
f"{name} {value}%" for name, value in item.application_ratio_breakdown.items()
|
||||||
|
)
|
||||||
|
row.note = f"반영률이 갈래마다 다릅니다 — {parts} (적용 후 수량)"
|
||||||
|
|
||||||
if not item.in_bill:
|
if not item.in_bill:
|
||||||
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
|
# 검산용 줄 — 수량은 보이되 **단가를 안 붙인다**(PLAN 8-7 ㉡ 와 같은 성격).
|
||||||
|
|||||||
@@ -26,13 +26,15 @@ KNOWN_GAPS: dict[str, tuple[str, str]] = {
|
|||||||
# 잘려 안 보였던 것이다. 잘린 자리를 사유가 없는 자리로 읽지 말 것.)
|
# 잘려 안 보였던 것이다. 잘린 자리를 사유가 없는 자리로 읽지 말 것.)
|
||||||
"FP-11-02": (
|
"FP-11-02": (
|
||||||
"인력 품만",
|
"인력 품만",
|
||||||
"ⓘ 지금 값은 **인력 품만**입니다. 재료가 서면 손율이 함께 걸립니다 — "
|
"ⓘ 이 단가는 **인력 품만**입니다 — 목재·못은 **자재 축**으로 따로 갑니다"
|
||||||
"품셈 11-2 [주]③ 「목재의 손율은 1개소 사용당 50%」.",
|
"(2026-09-09 각재·판재·못이 자재 축에 섰음. 다만 관급·사급이 안 갈려 아직 금액 0). "
|
||||||
|
"재료가 금액으로 서면 손율이 함께 걸립니다 — 품셈 [주]③ 「목재의 손율은 1개소 사용당 50%」.",
|
||||||
),
|
),
|
||||||
"FP-11-03": (
|
"FP-11-03": (
|
||||||
"인력 품만",
|
"인력 품만",
|
||||||
"ⓘ 지금 값은 **인력 품만**입니다. 재료가 서면 손율이 함께 걸립니다 — "
|
"ⓘ 이 단가는 **인력 품만**입니다 — 목재·못은 **자재 축**으로 따로 갑니다"
|
||||||
"품셈 11-3 [주]③ 「목재의 손율은 1개소 사용당 80%」.",
|
"(2026-09-09 각재·판재·못이 자재 축에 섰음. 다만 관급·사급이 안 갈려 아직 금액 0). "
|
||||||
|
"재료가 금액으로 서면 손율이 함께 걸립니다 — 품셈 [주]③ 「목재의 손율은 1개소 사용당 80%」.",
|
||||||
),
|
),
|
||||||
"FP-12-25": (
|
"FP-12-25": (
|
||||||
"운반거리 미정",
|
"운반거리 미정",
|
||||||
|
|||||||
@@ -350,6 +350,10 @@ def _resolve_cell(catalog: ResourceCatalog, name_cell: str, cells: list[str]):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
#: 공종 단위로 인정하지 않는 말 — **품의 단위**(사람·날)이지 물리 수량이 아니다.
|
||||||
|
_NOT_A_WORK_ITEM_UNIT = frozenset({"인", "인당", "일", "일당", "인/일"})
|
||||||
|
|
||||||
|
|
||||||
def match_table(
|
def match_table(
|
||||||
node: dict[str, Any],
|
node: dict[str, Any],
|
||||||
table: dict[str, Any],
|
table: dict[str, Any],
|
||||||
@@ -395,6 +399,13 @@ def match_table(
|
|||||||
basis = table.get("basis_quantity")
|
basis = table.get("basis_quantity")
|
||||||
basis_quantity = None if basis is None else Decimal(str(basis))
|
basis_quantity = None if basis is None else Decimal(str(basis))
|
||||||
unit = table.get("basis_unit") or ""
|
unit = table.get("basis_unit") or ""
|
||||||
|
# ⚠ **「인」은 공종 단위가 아니다** — 사람 수다. 마스터가 본문·표에서 「인」을 밑수로
|
||||||
|
# 읽어 온 자리가 있고(드론방제·지상방제·야면석 채집·휘발유), 그대로 두면 「몇 인짜리
|
||||||
|
# 공종」이라는 뜻이 되어 **단위 불일치 검사가 엉뚱하게 통과**한다.
|
||||||
|
# ⚠ 원문 고치기는 마스터 쪽 몫(2026-09-09 데스크탑 메인) — 여기서는 **받는 쪽에서 막는다.**
|
||||||
|
# 짝 규칙은 `B09_Estimation_WorkItemUnit` 머리말에 이미 적어 둔 것과 같다.
|
||||||
|
if unit.strip() in _NOT_A_WORK_ITEM_UNIT:
|
||||||
|
unit = ""
|
||||||
|
|
||||||
# ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열).
|
# ⚠ **한 표에 밑수가 둘인 표가 있다** — 「㎡당 0.17 / ㎥당 0.64」(채집 13-2 계열).
|
||||||
# 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로
|
# 행-자원으로 읽으면 **앞줄만 잡고 뒷줄을 버린다** — 막돌 채집이 ㎡당 값을 ㎥ 단위로
|
||||||
|
|||||||
@@ -329,6 +329,23 @@ export function attachCulvertSets(
|
|||||||
): number {
|
): number {
|
||||||
if (sets.size === 0) return 0;
|
if (sets.size === 0) return 0;
|
||||||
let attached = 0;
|
let attached = 0;
|
||||||
|
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
|
||||||
|
// 측점을 만들 때 정수 미터가 같은 격자 측점이 있으면 그리로 스냅한다(횡단 파일명이
|
||||||
|
// 정수 미터라 두 측점이 한 파일을 덮어쓰는 것을 막는 가드). 관 440.241 은 측점 440.0 위에 선다.
|
||||||
|
// ⇒ 0.02m 로만 보면 그런 관은 어느 측점에도 안 붙어 **그림도 수량도 통째로 빠진다.**
|
||||||
|
// ⇒ **가장 가까운 측점 하나**는 거리와 무관하게 그 관의 자리로 본다(짝: 파이썬 `attach_culvert_sets`).
|
||||||
|
const owner = new Map<number, number>();
|
||||||
|
for (const [pipeChainage] of sets) {
|
||||||
|
let nearest: number | null = null;
|
||||||
|
for (const section of sections) {
|
||||||
|
const value = num(section.chainage_m, null);
|
||||||
|
if (value === null) continue;
|
||||||
|
if (nearest === null || Math.abs(value - pipeChainage) < Math.abs(nearest - pipeChainage)) {
|
||||||
|
nearest = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nearest !== null) owner.set(pipeChainage, nearest);
|
||||||
|
}
|
||||||
for (const section of sections) {
|
for (const section of sections) {
|
||||||
const chainage = num(section.chainage_m, null);
|
const chainage = num(section.chainage_m, null);
|
||||||
if (chainage === null) continue;
|
if (chainage === null) continue;
|
||||||
@@ -336,7 +353,7 @@ export function attachCulvertSets(
|
|||||||
// 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다.
|
// 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다.
|
||||||
let reach = CHAINAGE_TOLERANCE_M;
|
let reach = CHAINAGE_TOLERANCE_M;
|
||||||
if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2;
|
if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2;
|
||||||
if (Math.abs(chainage - pipeChainage) <= reach) {
|
if (Math.abs(chainage - pipeChainage) <= reach || owner.get(pipeChainage) === chainage) {
|
||||||
// ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬
|
// ⚠ 스펙에 **그 시설이 놓인 누가거리**를 함께 얹는다(2026-09-08, 짝: 파이썬
|
||||||
// `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면
|
// `attach_culvert_sets`). 세트는 폭의 절반까지 옆 측점에도 붙으므로, 이것이 없으면
|
||||||
// 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**.
|
// 소비처가 「소유 측점」을 못 가려 **같은 시설을 여러 측점에서 센다**.
|
||||||
|
|||||||
Reference in New Issue
Block a user