diff --git a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py index 9aa7803e..4d47aa47 100644 --- a/B08_Quantity/B08_Quantity_Engine_StructureFigure.py +++ b/B08_Quantity/B08_Quantity_Engine_StructureFigure.py @@ -15,7 +15,8 @@ 터파기 폭 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림) ⚠ 터파기 치수는 **`common_util_excavation` 한 벌**을 읽는다 — 횡단도가 쓰는 그 상수다. - ⚠ 뒷길이를 안 정한 장은 **그리지 않고 까닭을 적는다**(브레인 판정 — 빈칸 대신 까닭). + ⚠ 뒷길이를 안 정한 장은 **표가 선 그 기본값으로 그리고 표 사유와 같은 말을 적는다** + (2026-09-14 브레인 판정 — 표가 서면 그림도 섬). 까닭은 정말 못 그리는 장(큰돌쌓기 등)만. ⚠ **치수가 없는 것은 그리되 치수를 적지 않는다** — 막자갈(뒷채움)은 우리 식이 「입적에서 몸통·고임돌을 뺀 것」이라 폭·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다. @@ -27,7 +28,8 @@ from typing import Any from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( - BACK_LENGTH_KEYS, + _back_length, + back_length_default_note, wall_thickness, weep_hole_spec, ) @@ -64,16 +66,15 @@ _LABEL_SIZE = 4.0 / SCALE_MM_PER_M _DIM_SIZE = 3.4 / SCALE_MM_PER_M -def back_length_cm(sheet: dict[str, Any]) -> float | None: - """장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 그리지 않는다.""" +def back_length_cm(sheet: dict[str, Any]) -> tuple[int, str | None]: + """(표가 쓴 뒷길이 ㎝, 기본으로 섰으면 표 사유와 같은 한 줄). + + 양식 장은 양식이 푼 값(`formula_sheet.vars.L3`), 전개 장은 `_back_length` — 표와 한 벌. + """ options = sheet.get("options") or {} - raw = next( - (options[key] for key in BACK_LENGTH_KEYS if options.get(key) not in (None, "")), None - ) - try: - return float(raw) if raw is not None else None - except (TypeError, ValueError): - return None + solved = ((sheet.get("formula_sheet") or {}).get("vars") or {}).get("L3") + back = int(float(solved)) if isinstance(solved, (int, float)) else _back_length(options) + return back, back_length_default_note(options, back) def figure_reason(sheet: dict[str, Any]) -> str | None: @@ -83,8 +84,6 @@ def figure_reason(sheet: dict[str, Any]) -> str | None: return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT) if float(sheet.get("height_m") or 0.0) <= 0: return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)" - if back_length_cm(sheet) is None: - return "그림 없음 — **뒷길이를 고르면 그림이 섭니다**(두께가 뒷길이에서 나옵니다)" return None @@ -104,7 +103,7 @@ def build_figure(sheet: dict[str, Any]) -> list[dict[str, Any]] | None: return None options = sheet.get("options") or {} height_m = float(sheet.get("height_m") or 0.0) - back_cm = back_length_cm(sheet) or 0.0 + back_cm, default_note = back_length_cm(sheet) slope, slope_note = slope_of(sheet) top_t, bottom_t, _basis = wall_thickness(options, back_cm=back_cm, height_m=height_m) lean = slope * height_m @@ -156,7 +155,11 @@ def build_figure(sheet: dict[str, Any]) -> list[dict[str, Any]] | None: (right, height_m * 0.92), "left", ), - _text(f"뒷길이 ℓ₃ = {back_cm:g}㎝", (right, height_m * 0.72), "left"), + _text( + f"뒷길이 ℓ₃ = {back_cm}㎝{' (기본)' if default_note else ''}", + (right, height_m * 0.72), + "left", + ), _text("막자갈(뒷채움) — 수량은 아래 표", (right, height_m * 0.55), "left"), _text( f"물구멍 Ø{weep_diameter_mm} — {weep_area_m2:g}㎡당 1개소", @@ -179,6 +182,9 @@ def build_figure(sheet: dict[str, Any]) -> list[dict[str, Any]] | None: _LABEL_SIZE, ), ] + if default_note: + # 「이 값이 어디서 왔나」 — 표 사유 줄과 **글자까지 같게**(브레인 판정). + shapes.append(_text(default_note, (0.0, -0.80 - base_d), "left")) return shapes diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py index 0354465a..f518b782 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity.py @@ -30,1107 +30,55 @@ from __future__ import annotations -import json -import math -from pathlib import Path -from functools import lru_cache -from dataclasses import dataclass, field from typing import Any, Iterable -from common_util.common_util_structure_face_role import structure_face_role -from common_util.common_util_excavation import ( - BASIS_WALL, - WALL_BLINDING_DEPTH_M, - WALL_FOUNDATION_DEPTH_M, - WALL_TRENCH_CLEARANCE_M, - wall_trench_area_m2, - wall_trench_width_m, -) +from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork +from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status + +# ⓘ 2026-09-14 쪼갬 — 성분 계약 `_Base` · 돌 제원·계수 `_StoneSpec` · 돌쌓기 전개식 `_Masonry`. +# 밖에서 이 모듈 이름으로 가져가던 것은 그대로 닿게 다시 내보냄(부르는 쪽 44곳 불변). from B08_Quantity.B08_Quantity_Engine_ObservedUnit import ( - BASIS_DERIVED, - BASIS_OBSERVED, + BASIS_DERIVED, # noqa: F401 — 다시 내보냄 ObservedUnitTable, billing_of, expand_observed, load_observed_table, ) -from B08_Quantity.B08_Quantity_Engine_Formwork import annotate as annotate_formwork -from B08_Quantity.B08_Quantity_Engine_Formwork import shoring_status +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import ( # noqa: F401 — 다시 내보냄 + DESTINATION, + MIX_COMPONENTS, + RUBBLE_BASE_NAME, + Component, + StructureQuantity, + _num, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Masonry import ( # noqa: F401 — 다시 내보냄 + boulder_masonry, + stone_masonry, +) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( # noqa: F401 — 다시 내보냄 + BACK_LENGTH_KEYS, + BLINDING_THICKNESS_M, + COLLECTED_STONE_KEY, + FILL_CONCRETE_MPA_DEFAULT, + RUBBLE_BASE_THICKNESS_M, + STONE_BACK_LENGTH_TABLE, + STONE_KIND_OPTION, + STONE_MASONRY, + _back_length, + back_length_default_note, + face_slope_ratio, + fill_concrete_mpa, + is_collected_stone, + load_stone_kind_table, + stone_weight_per_m2, + wall_thickness, + wants_blinding, + wants_practice_coefficients, + weep_hole_spec, +) from common_util.common_util_quantity_spread import spread_by_unit - -# ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── -# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. -# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). -#: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다. -#: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다. -#: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`. -STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { - 25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None}, - 30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None}, - 35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575}, - 45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88}, - 55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10}, - 60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None}, - 75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None}, -} -DEFAULT_BACK_LENGTH_CM = 45 - -#: ⑤ 돌 중량이 어디서 온 값인가 (2026-09-08 사용자 확정 2차 ⑤ 「DB 확인 → 없으면 현재 유지, -#: 나중에 프로그램에서 확인할 수 있게 근거를 남길 것」). -#: ⚠ **품셈·교본에는 돌중량표가 없다** — 지식DB 전수 확인 결과 이 값이 나오는 곳은 -#: 실무 원단위 라이브러리 한 곳뿐이다(기술문서에는 그 라이브러리를 인용한 줄만 있음). -#: 그래서 값을 바꾸지 않고 **어디서 온 값인지**를 줄마다 달아 화면에서 되짚게 한다. -STONE_WEIGHT_SOURCE = "uljin_library" -STONE_WEIGHT_BASIS = ( - "⚠ 실무 관측값 — 울진 소광 원단위 라이브러리 「돌뒷길이별 원단위표」(35·45·55㎝만 있음)." - " 품셈·교본에는 돌중량표가 없음" -) - -#: ⭐ 사용자 확정 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_masonry` 주석). -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} - -#: ⚠⚠ 정본 전수 확인이 **H=1.0~3.0** 까지다 — 그보다 높은 벽은 **근거가 없다.** -#: 그리고 그 자리에서 품셈 13-4-4 [주]⑨ <뒤채움 조약돌 두께> 범위와 **갈린다**: -#: H 3.0 두께 0.359 (품셈 0.33~0.58 안) · H 3.5 0.354 (품셈 0.40~0.70 **아래**) -#: 까닭은 이 식의 두께(= 막자갈 ÷ 비탈면적)가 `0.375 ÷ √(1+경사²)` 로 거의 붙박이인데, -#: 품셈 표는 직고와 함께 두꺼워지기 때문이다 — **방향이 반대다.** -#: ⚠ 교본 7-3 은 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고** -#: 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 즉 **높은 벽이 실제로 설 수 있다.** -#: 값을 눅이거나 막지 않고 **그 사실을 사유로 드러낸다**(임의 확정 금지). -STONE_BACKFILL_VERIFIED_HEIGHT_M = 3.0 - -#: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다. -#: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로. -STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)" - - -#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. -#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. -#: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이 -#: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다. -STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" -STONE_KIND_PREFIX = "stone_kind_" -STONE_KIND_OPTION = "stone_kind" - - -@lru_cache(maxsize=1) -def load_stone_kind_table() -> dict[str, Any]: - """돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다.""" - folder = STONE_KIND_DIR - if not folder.is_dir(): - return {} - files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json")) - if not files: - return {} - try: - return json.loads(files[-1].read_text(encoding="utf-8")) - except (OSError, ValueError): - return {} - - -#: ⑨ 계수를 **어느 열로 볼 것인가** (2026-09-08 사용자 확정 2차 ⑨). -#: 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)이고, 「실무 관행」으로 두면 -#: 건설품셈 참고자료 한 벌(깬돌 계열 0.15 · 0.20)로 덮어쓴다. -#: ⚠ **빈 칸은 「안 정함」**이고 그때 기본(품셈)으로 선다 — 등록부 default 는 비어 있다. -#: 기본값을 등록부가 아니라 여기가 갖는 것은 `face_slope_ratio` 와 같은 결이다. -COEFF_BASIS_KEY = "stone_coeff_basis" -COEFF_BASIS_PRACTICE = {"실무 관행", "실무관행", "practice", "관행"} - - -def wants_practice_coefficients(options: dict[str, Any]) -> bool: - """계수를 실무 관행 열로 볼 것인가. 빈 칸이면 **아니오**(= 품셈 열).""" - return str(options.get(COEFF_BASIS_KEY) or "").strip() in COEFF_BASIS_PRACTICE - - -def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]: - """(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다. - - ⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다. - ⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다. - """ - table = load_stone_kind_table() - key = str(back_cm) - kind = str(options.get(STONE_KIND_OPTION) or "").strip() - if not table: - return {}, "" - if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]: - return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다") - practice = wants_practice_coefficients(options) - if not kind or practice: - fallback = table.get("fallback") or {} - message = ( - f"「{kind}」이지만 계수를 **실무 관행 열**(건설품셈 참고자료)로 덮어썼습니다 —" - " 기본은 품셈 열입니다(확정 ⑨)" - if practice and kind - else str(fallback.get("message") or "") - ) - return { - "wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key), - "fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key), - "backfill_ratio": fallback.get("backfill_ratio_of_back_length"), - "kind": "", - }, message - if kind not in (table.get("kinds") or []): - return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다" - wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key) - fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key) - ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind) - note = "" - if wedge is None: - note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다" - return { - "wedge_stone_m3_per_m2": wedge, - "fill_concrete_m3_per_m2": fill, - "backfill_ratio": ratio, - "kind": kind, - }, note - - -# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. -#: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09). -#: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26: -#: 골막이 평균두께 = {(ℓ3 + 0.1×H) + (ℓ3 + 0.4×H)} ÷ 2 -#: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H − 1) -#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을 -#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어 -#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는** -#: 결함이 조용히 남는다. -#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** -#: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) · -#: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**. -STONE_MASONRY = { - # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). - # 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다 - # (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가 - # **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고 - # 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다. - # ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가 - # 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다. - "sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리 - # ⭐ 2026-09-09 확정 2차 ② — **실무 구조물도 식**으로 갈았음(랩탑 보조가 소광리 원본 - # 네 탭에서 찾아 실측과 소수점까지 대조). - # 상부 두께 = 뒷길이(ℓ3) + 0.30 - # 하부 두께 = 상부 + 0.30 × (H − 1.0) - # ⚠ **옛 식에는 뒷길이가 없었다**(0.45 + 0.10·H / 0.45 + 0.40·H) — 뒷길이를 35 로 - # 바꿔도 두께가 안 바뀌어 돌 부피·막자갈·터파기가 다 같은 값으로 섰다. - # ⚠ 정본 xls 의 「상부 0.75 · 하부 0.90」은 **H=1.5 규칙값을 다른 높이 탭에 복사**한 - # 흔적이라 그대로 쓰면 H=1.5 밖에서 틀린다. - # ⚠ 돌조공(H=0.5 · 0.55/0.65)은 이 규칙 밖 — 다른 시설이라 여기서 다루지 않는다. - "thickness_top_add_m": 0.30, # 상부 = 뒷길이 + 0.30 - "thickness_slope_per_m": 0.30, # 하부 = 상부 + 0.30 × (H − 1.0) - "thickness_height_base_m": 1.0, # 그 식의 기준 높이 - # ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다. - # 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다. - "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 - # ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m · - # 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을 - # 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다. - # ⓘ 2026-09-13 — 돌쌓기(기슭막이)는 **평균두께**로 갈음(브레인 판정). - # 이 값은 골막이 정본용으로만 남음. - "weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 — 골막이 - "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) - # ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다 - # (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다). - # 여기서 다시 두면 그림과 물량이 갈린다. -} - -# 성분이 어디로 가는가 — 이중계상을 막는 표시. -# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증) -# `material` = 자재총괄로 감(할증은 거기서 한 번만) -# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해) -#: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭). -#: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고 -#: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」. -#: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의 -#: 폭과 동일**하게 한다」. -#: ⭐ 그 잡석다짐 폭이 **정해졌다** — 「기준 구조물에서 유도, 같은 값을 공유」 -#: (2026-09-08 사용자 확정 2차 ⑪ 「기슭막이면 **하단 길이** × 종단방향 폭 × 높이」). -#: ⇒ 벽에서 **하단 길이 = 하부 두께**다. 종전의 「터파기 폭 잠정」을 그 값으로 바꾼다. -#: ⚠ **기초잡석(품셈 12-25) 물량은 아직 못 센다** — 폭은 이제 있으나 **두께가 어느 -#: 원문에도 없다**(품셈은 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 -#: **다른 구조물 값을 옮겨 쓰지 않고** 사유로 드러낸다. -#: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②). -#: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서 -#: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가 -#: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다. -#: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다. -STONE_SUPPLY_KEYS = ("stone_supply", "stone_source") -STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"} - -#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도 -#: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다). -#: -#: 채집석 공제는 사토에서 한 번만 뺀다. -#: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, -#: 빼는 자리는 유토곡선의 사토뿐이다 — -#: 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. -#: -#: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번 -#: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다. -COLLECTED_STONE_KEY = "collected_stone_deduction_m3" - -#: ⑩ 채움 콘크리트 강도 (2026-09-08 사용자 확정 2차 ⑩ 「기본 210, 고를 수 있게」). -#: ⚠ **빈 칸은 「안 정함」**이고 그때 210 으로 선다 — 등록부 default 는 비어 있다. -#: 180 은 국가기준 하한(돌쌓기 전용 값)이라 고르면 그 값이 이긴다. -FILL_CONCRETE_MPA_KEY = "fill_concrete_mpa" -FILL_CONCRETE_MPA_DEFAULT = "210" -FILL_CONCRETE_MPA_CHOICES = ("180", "210") - - -def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]: - """(강도, 근거). 안 정했으면 기본 210 이고 그 사실이 근거에 적힌다.""" - value = str(options.get(FILL_CONCRETE_MPA_KEY) or "").strip() - if value in FILL_CONCRETE_MPA_CHOICES: - return value, f"강도 {value} — 저장 제원에서 고른 값" - if value: - return ( - FILL_CONCRETE_MPA_DEFAULT, - f"강도 {FILL_CONCRETE_MPA_DEFAULT} — ⚠ 저장값 「{value}」은 180·210 중에" - " 없어 기본으로 섬", - ) - return ( - FILL_CONCRETE_MPA_DEFAULT, - f"강도 {FILL_CONCRETE_MPA_DEFAULT} — 안 정해 기본값(확정 ⑩)", - ) - - -BLINDING_THICKNESS_M = 0.10 - -#: 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」. -#: ⚠ 품셈 12-25 는 **㎥당 품만** 주고 두께를 정하지 않는다(전 출처 소진). 폭은 **버림 폭과 -#: 같고**(KCS 34 50 05 「버림 콘크리트의 폭은 잡석다짐의 폭과 동일」) 그 폭은 확정 ⑪ 로 -#: 하단 길이다. ⇒ **기초잡석 = 버림 × (잡석두께 ÷ 버림두께)** 로 나온다 — 같은 폭·연장이라 -#: 두께 비만 곱하면 된다. 관측 원단위로 오는 구조물(옹벽)도 같은 식으로 선다 -#: (버림 0.15㎥/m ÷ 0.1 = 폭 1.5m ⇒ 잡석 0.30㎥/m — 확정 3차 ② 의 그 값). -RUBBLE_BASE_THICKNESS_M = 0.2 -RUBBLE_BASE_NAME = "기초잡석" -#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭). -#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다. -BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding") - -DESTINATION = { - "터파기": "earthwork", - "되메우기": "earthwork", - "잔토처리": "earthwork", - "돌쌓기": "unit_price", - "돌붙임": "unit_price", - "깬돌": "material", - "고임돌": "material", - "막자갈": "material", - # ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시). - # ⚠ 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다(서브 일위대가도 재료 0원). - # 그래서 자재로 안 보내면 **재료비가 통째로 빠진다** — B09 매김에서 드러난 자리다. - # ⚠ 배합을 분해하지 않는 규칙(㉢)은 그대로다 — 「콘크리트 ㎥」에서 멈춘다. - "콘크리트": "material", - "채움콘크리트": "material", - "버림콘크리트": "material", - "모르터": "unit_price", - "거푸집": "unit_price", - "물구멍관": "material", - # ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은 - # `material` 만 모으므로 여기 섞이지 않는다. - "채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약) - # ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에 - # 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에 - # 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다. - # ⚠ 「석적」은 정본에 없고 소광리 시트에만 있으나 **확정 5차로 세운다** — 같은 - # 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다. - "입적": "reference", - "석적": "reference", - # 돌 줄은 **이름 「돌」 + 규격(`spec`)에 종류**다(명세 13장 Ⓒ) — 정본 계산표 줄 이름 그대로 - # (「돌 ℓ3=45cm」). 자재총괄이 (이름·규격·단위)로 묶으므로 종류 이름을 따로 두지 않는다. - "돌": "material", - # 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다. - # 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다. - RUBBLE_BASE_NAME: "unit_price", -} - -# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. -# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로 -# 오탐된다 — 개발 중 실제로 걸렸던 자리다. -MIX_COMPONENTS = frozenset( - {"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"} -) - - -@dataclass(slots=True) -class Component: - """전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다.""" - - name: str - unit: str - amount: float - destination: str - basis: str = "" - # ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). - # 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다. - basis_kind: str = BASIS_DERIVED - source: str = "" - #: 규격 — 같은 이름이라도 단가가 갈리는 값(채움 콘크리트 강도 등). 받는 쪽이 읽는다. - spec: str = "" - - -@dataclass(slots=True) -class StructureQuantity: - """구조물 하나의 원단위 전개 결과.""" - - structure_id: str | None - type_id: str - name: str - length_m: float = 0.0 - height_m: float = 0.0 - # 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계). - start_m: float | None = None - end_m: float | None = None - # 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다. - # 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나). - options: dict[str, Any] = field(default_factory=dict) - components: list[Component] = field(default_factory=list) - notes: list[str] = field(default_factory=list) - #: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인 - #: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증). - #: 비어 있으면 종전대로 「m · 연장」으로 선다. - billing_unit: str = "" - billing_quantity: float = 0.0 - #: 구조물이 놓인 자리의 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`)와 그 판정 근거. - #: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다. - ground_type: str | None = None - ground_type_basis: str = "" - #: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값. - library_item: str = "" - - -#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다. -SLOPE_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" -SLOPE_PREFIX = "masonry_slope_" -#: 사용자가 덮어쓰는 칸 — 값이 있으면 **그 값이 이긴다**(확정 ⑨). -FACE_SLOPE_KEYS = ("face_slope_ratio",) -#: 성토/절토는 **측점 단면유형(`design.section_mode`) + 설치 측**으로 판정한다 -#: (`common_util_structure_face_role` 한 벌). 못 가르는 자리(`both_cut` + 「자동」)만 남아 -#: 그때 쓸 칸을 여기 한 곳에 둔다 — **눅여 쓰는 것이 아니라 종전값으로 서고 사유를 적는다.** -DEFAULT_SLOPE_FACE = "성토" -LEGACY_FACE_SLOPE_RATIO = 0.3 - - -@lru_cache(maxsize=1) -def load_slope_table(path: str | None = None) -> dict[str, Any]: - """표준경사표. 없으면 빈 표 — 그때는 종전 기본값으로 돈다(값을 지어내지 않는다).""" - target = Path(path) if path else None - if target is None: - files = sorted(SLOPE_DIR.glob(SLOPE_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else [] - target = files[-1] if files else None - if target is None or not target.is_file(): - return {} - return json.loads(target.read_text(encoding="utf-8")) - - -def face_slope_ratio( - options: dict[str, Any], - *, - wet: bool, - height_m: float, - face: str | None = None, - face_reason: str = "", - table: dict[str, Any] | None = None, -) -> tuple[float, str]: - """(기울기 1:n 의 n, 근거 문구). - - ⚠ **사용자가 정했으면 그 값이 이긴다**(확정 ⑨). 안 정했으면 품셈 13-4-4 [주]⑪ - <표준경사>에서 **직고 · 메/찰 · 성토/절토** 셋으로 고른다. - ⚠ 자동으로 고른 값을 **저장에 박지 않는다** — 「정한 적 없음」과 「0.3 으로 정함」이 - 구별돼야 한다(오늘 패널 접힘에서 지킨 것과 같은 자리). - ⚠ 경계값은 **앞 칸**이다 — 원문이 `∼3` 이라 직고 3.0m 은 `~3` 칸이다. - """ - for key in FACE_SLOPE_KEYS: - given = _num(options.get(key), 0.0) - if options.get(key) is not None and given > 0: - return given, f"사용자 지정 1:{given:g}" - - found = table if table is not None else load_slope_table() - steps = found.get("steps_m") or [] - bond = "찰쌓기" if wet else "메쌓기" - # ⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다. 못 가르면 - # `None` 이 오는데, **성토로 눅이지 않는다** — 임의값이 금액으로 굳으면 안 된다. - # 그때는 종전값으로 서되 **왜 못 갈랐는지**를 근거에 적는다. - if not face: - why = face_reason or "성절토를 가를 근거 없음" - return ( - LEGACY_FACE_SLOPE_RATIO, - f"⚠ {why} — 표준경사표를 못 골라 종전값 1:{LEGACY_FACE_SLOPE_RATIO} 로 섰음", - ) - row = ((found.get("table") or {}).get(bond) or {}).get(face) - if not row: - return LEGACY_FACE_SLOPE_RATIO, f"표준경사표를 못 읽어 종전값 1:{LEGACY_FACE_SLOPE_RATIO}" - - index = len(steps) - for i, limit in enumerate(steps): - if height_m <= float(limit): - index = i - break - ratio = float(row[min(index, len(row) - 1)]) - label = f"직고 {height_m:g}m" + (f" ≤{steps[index]:g}m" if index < len(steps) else " 7m 초과") - note = f"품셈 13-4-4 [주]⑪ 표준경사 · {bond} {face} · {label} → 1:{ratio:g}" - if face_reason: - note += f" · {face_reason}" - return ratio, note - - -def is_collected_stone(options: dict[str, Any]) -> bool: - """이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②).""" - for key in STONE_SUPPLY_KEYS: - raw = options.get(key) - if raw is None: - continue - if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED: - return False - return True - - -def wants_blinding(options: dict[str, Any]) -> bool: - """버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭). - - 「안 넣음」·「제외」·`false` 로 적혀 있을 때만 뺀다 — 빈 칸을 「빼기」로 읽으면 - 저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다. - """ - for key in BLINDING_OPTION_KEYS: - raw = options.get(key) - if raw is None: - continue - text = str(raw).strip().lower() - if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}: - return False - return True - - -def _blinding_component( - base_width_m: float, - length_m: float, - options: dict[str, Any], - width_basis: str = "", -): - """버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`. - - 폭은 **잡석다짐 폭**(KCS 34 50 05)이고, 그 값은 확정 ⑪ 로 **구조물 하단 길이**다. - 부르는 쪽이 그 폭과 까닭(`width_basis`)을 함께 넘긴다. - """ - if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0: - return None - basis = ( - f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m" - " · 두께는 KDS 44 90 00(100㎜) · 폭은 잡석다짐 폭(KCS 34 50 05)" - ) - if width_basis: - basis = f"{basis} = {width_basis}" - return Component( - "버림콘크리트", - "㎥", - base_width_m * length_m * BLINDING_THICKNESS_M, - DESTINATION["버림콘크리트"], - basis, - ) - - -#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). -#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** -#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. -#: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다). -BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") - - -def _back_length(options: dict[str, Any]) -> int: - """저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45. - - ⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 - 다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** — - 접으면 다른 규격 계수가 조용히 돈다. - """ - for key in BACK_LENGTH_KEYS: - raw = options.get(key) - if raw is None: - continue - try: - value = int(float(raw)) - except (TypeError, ValueError): - continue - # ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가 - # 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). - # 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다. - return value - return DEFAULT_BACK_LENGTH_CM - - -def _num(value: Any, fallback: float = 0.0) -> float: - return float(value) if isinstance(value, (int, float)) else fallback - - -#: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 2차 ② 후반 -#: 「사용자가 값을 바꿀 수 있게」). 야면석 계수·채움 강도와 같은 자리다. -WALL_THICKNESS_KEYS: tuple[str, str] = ("thickness_top_m", "thickness_bottom_m") - - -def wall_thickness( - options: dict[str, Any], *, back_cm: float, height_m: float -) -> tuple[float, float, str]: - """(상부 두께 m, 하부 두께 m, 근거 문구) — **사용자가 넣은 값이 식을 이긴다.** - - ⚠ 빈 칸은 「안 정함」이다 — 자동으로 나온 값을 저장에 박지 않는다(`face_slope_ratio` - 와 같은 태도). 상부만 넣으면 하부는 **그 상부 위에서** 식을 이어 간다 — 벽 모양 - (아래로 벌어지는 기울기)을 사용자 값 위에 그대로 얹는 것이 자연스럽다. - """ - - def given(key: str) -> float | None: - raw = options.get(key) - if raw in (None, ""): - return None - try: - value = float(raw) - except (TypeError, ValueError): - return None - return value if value > 0 else None - - constants = STONE_MASONRY - back_m = back_cm / 100.0 - top_given = given(WALL_THICKNESS_KEYS[0]) - top = top_given if top_given is not None else back_m + constants["thickness_top_add_m"] - rise = constants["thickness_slope_per_m"] * max( - height_m - constants["thickness_height_base_m"], 0.0 - ) - bottom_given = given(WALL_THICKNESS_KEYS[1]) - bottom = bottom_given if bottom_given is not None else top + rise - top_basis = ( - f"상부 {top:.2f}(사용자 입력)" - if top_given is not None - else f"상부 {top:.2f}(뒷길이 {back_cm:g}㎝ + {constants['thickness_top_add_m']:g})" - ) - bottom_basis = ( - f"하부 {bottom:.2f}(사용자 입력)" - if bottom_given is not None - else ( - f"하부 {bottom:.2f}(상부 + {constants['thickness_slope_per_m']:g}" - f"×(H−{constants['thickness_height_base_m']:g}))" - ) - ) - return top, bottom, f"실무 구조물도 식 — {top_basis} · {bottom_basis}" - - -#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인). -#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가 -#: 「뒷길이 45㎝」 계수로 돌던 자리다. -BOULDER_DIAMETERS = ("40~60", "60~80", "80~100") - -#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. -#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다. -BOULDER_MASONRY = { - # ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것. - # 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다. - "sheet_check_factor_at_0_3": 1.04, - # ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다 - # (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다). - # 여기서 다시 두면 그림과 물량이 갈린다. -} - - -def _boulder_diameter(options: dict[str, Any]) -> str | None: - """저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**.""" - raw = options.get("stone_cm") - text = str(raw).strip() if raw is not None else "" - return text if text in BOULDER_DIAMETERS else None - - -def boulder_masonry( - height_m: float, length_m: float, options: dict[str, Any] -) -> tuple[list[Component], list[str]]: - """큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다. - - ⚠ 재료 원단위가 품셈에 없다 — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. - 돌쌓기(13-4)처럼 「뒷길이별 돌중량·고임돌」 표가 **없으므로** 큰돌·고임돌·막자갈을 - 내지 않는다. 대신 **왜 못 내는지**를 알림으로 남긴다. - - ⚠ 고임돌·채움콘크리트 품은 **품에 포함**돼 있다(13-6-1 [주]①·13-6-2 [주]①) — - 따로 세우면 이중계상이다. - - ⚠ 뒤채움(조약돌)은 **13-3 을 적용**한다(13-6 [주]⑧) — 이 표에서 찾지 않는다. - 두께는 13-4-3 [주]⑨ 를 준용하는데 **직고별 범위값**(상부 20~40㎝ / 하부 30~140㎝)이라 - 한 값으로 못 정한다. 미확보로 둔다. - """ - notes: list[str] = [] - if height_m <= 0 or length_m <= 0: - return [], ["높이·연장이 없어 전개하지 않음"] - - diameter = _boulder_diameter(options) - if diameter is None: - return [], [ - "큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " - f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다" - ] - - # ⚠ 큰돌쌓기는 **표준경사표 대상이 아니다** — 교본이 「1:0.3 **이상**」으로만 두었다. - # 저장 제원에 값이 있으면 그것을 쓰고, 없으면 하한 0.3 으로 둔다. - slope_ratio = _num(options.get("face_slope_ratio"), 0.3) - face_area = height_m * length_m - # 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘). - masonry_area = face_area * math.hypot(1.0, slope_ratio) - - components = [ - Component( - "큰돌쌓기", - "㎡", - masonry_area, - DESTINATION["돌쌓기"], - f"정면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)", - ) - ] - - # 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다. - # ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로 - # 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다. - upper_cm = float(str(diameter).split("~")[-1]) - thickness = upper_cm / 100.0 - # 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」. - blinding = _blinding_component( - thickness, - length_m, - options, - f"하단 길이(직경 위끝) {thickness:.2f}m — 확정 ⑪ 준용", - ) - if blinding is not None: - components.append(blinding) - components.extend( - _trench_components( - height_m, - length_m, - thickness, - options, - notes, - "⚠ 큰돌쌓기 터파기 폭 규정이 품셈에 없어 돌쌓기 방식을 준용", - ) - ) - - notes.append( - "재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 " - "돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음" - ) - notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음") - notes.append( - "뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 " - "직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보" - ) - # ⚠ **채워져 있으면 그 사유를 붙이지 않는다**(2026-09-09). `bond` 는 레지스트리에 이미 - # 있는 칸인데 조건 없이 붙이고 있어, 값을 고른 뒤에도 「못 고름」이 뜨고 있었다. - # ⚠ 얻는 것은 **노무 품 갈래뿐**이고 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다 — - # 그래도 고치는 까닭은 **사유가 거짓이면 사유 칸 전체를 못 믿게 되기 때문**이다. - if not str(options.get("bond") or "").strip(): - notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름") - return components, notes - - -def _trench_components( - height_m: float, - length_m: float, - thickness: float, - options: dict[str, Any], - notes: list[str], - extra_basis: str = "", -) -> list[Component]: - """터파기·되메우기·잔토 — **공용 단면 함수 한 벌**(`common_util_excavation`). - - ⚠ 여기서 식을 다시 짜지 않는다 — 화면(B06 횡단도)이 그리는 단면과 같은 코드라야 - 그림과 물량이 안 갈린다. 평균두께는 이쪽이 낸 값을 **넘겨 준다**(저쪽은 두께를 모름). - ⚠ 「기초유 / 기초버림」은 정본 탭 제목이고 **기초분 0.45 냐 0.07 이냐**를 가른다. - 안 고른 프로젝트는 **비탈분만** 세우고 사유를 남긴다 — 한쪽으로 찍으면 임의값이 - 금액으로 굳는다(기초분이 통째로 0.45㎥/m 다). - """ - foundation = str(options.get("foundation") or "").strip() - if foundation in ("기초유", "기초버림"): - has_foundation = foundation == "기초유" - area = wall_trench_area_m2(height_m, thickness, has_foundation=has_foundation) - base_depth = WALL_FOUNDATION_DEPTH_M if has_foundation else WALL_BLINDING_DEPTH_M - base_basis = f"기초분({foundation}) + " - else: - width = wall_trench_width_m(thickness) - area = None if width is None else height_m * width - base_depth = 0.0 - base_basis = "" - notes.append( - "⚠ 기초 유/무(기초유·기초버림)를 안 정해 **기초 터파기 몫이 안 섰음** — 비탈분만 세움" - ) - if area is None: - notes.append("⚠ 높이·평균두께가 없어 터파기가 서지 않음") - return [] - excavation = area * length_m - backfill = (base_depth + height_m) * WALL_TRENCH_CLEARANCE_M * length_m - basis = ( - f"{base_basis}H × (평균두께 {thickness:.2f} + {WALL_TRENCH_CLEARANCE_M}) × 연장" - f" · {BASIS_WALL}" - ) - if extra_basis: - basis = f"{basis} · {extra_basis}" - return [ - Component("터파기", "㎥", excavation, DESTINATION["터파기"], basis), - Component( - "되메우기", - "㎥", - backfill, - DESTINATION["되메우기"], - f"(기초깊이 {base_depth:g} + H) × {WALL_TRENCH_CLEARANCE_M} × 연장", - ), - Component( - "잔토처리", - "㎥", - excavation - backfill, - DESTINATION["잔토처리"], - "터파기 − 되메우기", - ), - ] - - -def stone_masonry( - height_m: float, - length_m: float, - options: dict[str, Any], - wet: bool, - face: str | None = None, - face_reason: str = "", -) -> tuple[list[Component], list[str]]: - """돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식. - - 실측 대조(m당, H=1.5, 뒷길이 45㎝, 기울기 1:0.3): - 정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245 - 터파기 1.55 · 되메우기 0.30 · 잔토 1.25 - """ - notes: list[str] = [] - if height_m <= 0 or length_m <= 0: - return [], ["높이·연장이 없어 전개하지 않음"] - - back_cm = _back_length(options) - if back_cm not in STONE_BACK_LENGTH_TABLE: - # ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다. - return [], [ - f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다" - ] - table = dict(STONE_BACK_LENGTH_TABLE[back_cm]) - # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). - # 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고 - # 멈추면 이미 저장된 프로젝트가 통째로 빈다. - picked, kind_note = stone_coefficients(options, back_cm) - if kind_note: - notes.append(kind_note) - # ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이 - # 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음). - if picked.get("kind"): - for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): - table[key] = picked.get(key) - else: - for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): - if picked.get(key) is not None: - table[key] = picked[key] - # ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** — - # ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로 - # 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다. - kind_label = str(picked.get("kind") or "") - # ⭐ 2026-09-13 브레인 판정 — **계수 열 고르기와 돌종류는 다른 축.** 「실무 관행」은 계수 열만 - # 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 규격·무게는 - # **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 규격 빈칸). - chosen_kind = str(options.get(STONE_KIND_OPTION) or "").strip() - stone_kind_name = ( - chosen_kind if chosen_kind in (load_stone_kind_table().get("kinds") or []) else kind_label - ) - # ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도 - # 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이 - # 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.) - # **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** / - # 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」 - # (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`). - # 전면 기울기 — 품셈 표준경사표로 자동 판정하고, 저장 제원에 값이 있으면 그것이 이긴다. - slope_ratio, slope_basis = face_slope_ratio( - options, wet=wet, height_m=height_m, face=face, face_reason=face_reason - ) - constants = STONE_MASONRY - - face_area = height_m * length_m # 정면적 - # 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.** - # 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04). - masonry_area = face_area * math.hypot(1.0, slope_ratio) - # 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.** - # ⚠ 표준도 제원에 상부·하부 두께 칸이 있으면 **그 값이 식을 이긴다**(확정 ② 후반). - back_m = back_cm / 100.0 # 석적(아래)이 뒷길이를 m 로 쓴다 - top_thickness, bottom_thickness, thickness_basis = wall_thickness( - options, back_cm=back_cm, height_m=height_m - ) - thickness = (top_thickness + bottom_thickness) / 2.0 - volume = face_area * thickness # 입적 - - components = [ - Component( - "돌쌓기", - "㎡", - masonry_area, - DESTINATION["돌쌓기"], - f"정면적 × √(1+{slope_ratio:g}²) — 비탈면적 · {slope_basis}", - ), - # 입적 — 정본 계산표 좌측 열의 이름 그대로다(확정 ⑦). 값을 새로 내는 것이 아니라 - # 이미 쓰고 있던 밑수를 화면에 드러내는 줄이다. - Component( - "입적", - "㎥", - volume, - DESTINATION["입적"], - f"정면적 × 평균두께 {thickness:.2f} · {thickness_basis}", - ), - ] - # ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25). - # 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과 - # 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다. - if table["wedge_stone_m3_per_m2"] is None: - notes.append( - f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝" - + (f" · {kind_label}" if kind_label else "") - + " 칸이 「-」입니다" - ) - else: - components.append( - Component( - "고임돌", - "㎥", - masonry_area * _num(table["wedge_stone_m3_per_m2"]), - DESTINATION["고임돌"], - f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" - + (f" · {kind_label}" if kind_label else ""), - ) - ) - - # 석적 — ⭐ 확정 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, stone_kind_name, table["stone_ton_per_m2"] - ) - weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else "" - if not stone_kind_name: - notes.append( - "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — " - "야면석이면 계산식이 안 맞아 관측표로 갈립니다" - ) - if stone_ton is None: - # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). - notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함") - else: - components.append( - Component( - "돌", - "ton", - masonry_area * stone_ton, - DESTINATION["돌"], - weight_basis, - source=weight_source, - spec=stone_kind_name, - ) - ) - - # 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가 - # 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다. - backfill_top = STONE_BACKFILL_WIDTH_M["top"] - backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"] - rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m - components.append( - Component( - "막자갈", - "㎥", - rubble, - DESTINATION["막자갈"], - f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m" - f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값", - ) - ) - notes.append( - f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · " - f"하 {backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다" - "(소광리는 같은 식에 0.30/0.60 을 씁니다)" - ) - # ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가 - # 없으면 아무도 못 본다(2026-09-09 그물 침). - if height_m > STONE_BACKFILL_VERIFIED_HEIGHT_M: - notes.append( - f"직고 {height_m:g}m 는 뒷채움 폭 정본 확인 구간" - f"(H {STONE_BACKFILL_VERIFIED_HEIGHT_M:g}m 이하) **밖**입니다 — 붙박이 폭이라 " - "직고가 커져도 두께가 안 늘어 품셈 13-4-4 [주]⑨ 뒤채움 조약돌 두께 범위보다 " - "얇아집니다(그 표는 직고와 함께 두꺼워짐). 값은 정본 식대로 세우되 " - "**높은 벽의 뒷채움 폭은 실무자 확인이 필요합니다**" - ) - - if wet: - mpa, mpa_basis = fill_concrete_mpa(options) - components.append( - Component( - "채움콘크리트", - "㎥", - masonry_area * _num(table["fill_concrete_m3_per_m2"]), - DESTINATION["채움콘크리트"], - # ⚠ 돌 종류로 계수가 갈리는데(깬돌 0.2 · 야면석 0.15) 근거에 종류가 안 - # 적히던 자리다 — 값은 바뀌는데 **왜 바뀌었는지가 안 보였다**(고임돌은 - # 적고 있었다). 2026-09-09 맞춤. - f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" - + (f" · {kind_label}" if kind_label else "") - + f" · {mpa_basis}", - spec=f"{mpa}", - ) - ) - components.append( - Component( - "모르터", - "㎥", - masonry_area * constants["mortar_m3_per_m2"], - DESTINATION["모르터"], - f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)", - ) - ) - # ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢). - - # 물구멍 — 벽면적 2㎡당 1개소, 개소당 관 길이 = **평균두께**(관이 벽을 가로지름). - # ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고 - # 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다. - # ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에 - # 「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 한다. 그 쪽을 쓰면 - # 이 줄과 겹친다. **우리 선택은 이 줄을 세우고 제잡비는 아랫단(미설치)** 이다 - # (`structure_unit_observed` 의 `double_count_rules`). - # 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인). - # ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면 - # 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다. - # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 — - # **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식). - # ⭐ 2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = **평균두께**. 정본이 그렇게 잡고 - # 관은 벽을 가로지르므로 이치에도 맞음. 옛 상수 0.5m 는 그만큼 짧게 섰음. - # ⚠ 골막이(`_Revetment`)는 그 정본이 0.5 라 그대로 둠 — 여기는 기슭막이 계열만. - hole_area, hole_dia, hole_basis = weep_hole_spec(options) - components.append( - Component( - "물구멍관", - "m", - masonry_area / hole_area * thickness, - DESTINATION["물구멍관"], - f"돌쌓기 ÷ {hole_area:g}㎡/개소 × 평균두께 {thickness:.2f} m/개소 · {hole_basis}", - spec=f"Ø{hole_dia}", - ) - ) - - # 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.** - # ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고 - # 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다. - if is_collected_stone(options): - wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 - collected = max(stone_pile + wedge + rubble, 0.0) - if collected > 0: - components.append( - Component( - "채집석", - "㎥", - collected, - DESTINATION["채집석"], - f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f} ㎥" - " · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌", - ) - ) - - # 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다. - # 잡석다짐 폭 = **하단 길이**(확정 ⑪) — 벽에서는 하부 두께다. 버림도 같은 폭을 쓴다. - base_width = bottom_thickness - blinding = _blinding_component( - base_width, - length_m, - options, - f"하단 길이(하부 두께) {bottom_thickness:.2f}m — 확정 ⑪ 「기준 구조물에서 유도」", - ) - if blinding is not None: - components.append(blinding) - # ⓘ 기초잡석은 `build_table` 이 버림 뒤에 세움(확정 3차 ② 두께 0.2 · PLAN 10장 「계상함」). - # 옛 「두께가 없어 안 섬」 사유는 걷음 — 줄과 사유가 함께 떠 서로 어긋났음(2026-09-13 화면 실측). - - # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). - components.extend( - _trench_components(height_m, length_m, thickness, options, notes, thickness_basis) - ) - return components, notes - +from common_util.common_util_structure_face_role import structure_face_role # 구조물 종류 → 전개식. 없는 종류는 전개하지 않고 이름만 남긴다(지어내지 않는다). # ⚠ **관측 원단위표로 가는 종류** — 치수가 저장돼 있지 않아 전개식을 못 세우는 것들이다. diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py new file mode 100644 index 00000000..7e3d4ef8 --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Base.py @@ -0,0 +1,112 @@ +"""구조물 원단위 — **성분 계약과 갈 곳**(이중계상 경계) · 2026-09-14 `UnitQuantity` 에서 가름. + +⚠⚠ 이 파일의 `DESTINATION`·`MIX_COMPONENTS` 가 이중계상 경계다 — 규칙 셋은 `UnitQuantity` 머리글. + ㉢ 배합을 분해하지 않음(`MIX_COMPONENTS`) · ㉠ 할증 안 붙임 · + 터파기·되메우기·잔토는 토공으로(`DESTINATION`). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_ObservedUnit import BASIS_DERIVED + +#: 기초잡석 줄 이름 — 갈 곳 표(`DESTINATION`)가 이 이름을 키로 씀. 두께 상수는 `_StoneSpec`. +RUBBLE_BASE_NAME = "기초잡석" + + +DESTINATION = { + "터파기": "earthwork", + "되메우기": "earthwork", + "잔토처리": "earthwork", + "돌쌓기": "unit_price", + "돌붙임": "unit_price", + "깬돌": "material", + "고임돌": "material", + "막자갈": "material", + # ⭐ 2026-09-09 사용자 확정 3차 ⑥ — **콘크리트는 자재 축에 세운다**(사용자 명시). + # ⚠ 타설 줄(품셈 12-1)은 **품만** 주고 재료를 안 준다(서브 일위대가도 재료 0원). + # 그래서 자재로 안 보내면 **재료비가 통째로 빠진다** — B09 매김에서 드러난 자리다. + # ⚠ 배합을 분해하지 않는 규칙(㉢)은 그대로다 — 「콘크리트 ㎥」에서 멈춘다. + "콘크리트": "material", + "채움콘크리트": "material", + "버림콘크리트": "material", + "모르터": "unit_price", + "거푸집": "unit_price", + "물구멍관": "material", + # ⚠ 자재도 토공도 아닌 자리 — **유토곡선이 사토에서 뺄 밑수**다. 자재총괄은 + # `material` 만 모으므로 여기 섞이지 않는다. + "채집석": "haul_deduction", # ⚠ 자재 카탈로그가 이름으로 찾는다 — 공백 없는 한 낱말(B09 규약) + # ⚠ **보여 주기만 하는 줄** — 자재도 토공도 일위대가도 아니다. 정본 계산표 좌측 열에 + # 「입적」으로 실려 있어 그 이름 그대로 낸다(2026-09-08 확정 ⑦). 값은 이미 식 안에 + # 있던 것을 줄로 꺼낸 것뿐이고, `material`·`earthwork` 어디에도 안 섞인다. + # ⚠ 「석적」은 정본에 없고 소광리 시트에만 있으나 **확정 5차로 세운다** — 같은 + # 보여 주기 줄이고, 「소광리에만 있는 줄」임을 사유에 적는다. + "입적": "reference", + "석적": "reference", + # 돌 줄은 **이름 「돌」 + 규격(`spec`)에 종류**다(명세 13장 Ⓒ) — 정본 계산표 줄 이름 그대로 + # (「돌 ℓ3=45cm」). 자재총괄이 (이름·규격·단위)로 묶으므로 종류 이름을 따로 두지 않는다. + "돌": "material", + # 기초잡석(품셈 12-25) — 운반·부설·다짐 품이 붙는 **공종**이라 일위대가로 간다. + # 자재총괄로 보내면 같은 잡석이 재료로 한 번 더 선다. + RUBBLE_BASE_NAME: "unit_price", +} + +# ⚠ 배합 성분 — 산출물에 나타나면 안 된다(㉢). B09 일위대가가 배합표로 분해한다. +# ⚠ **정확히 같은 이름**으로만 본다. 부분문자열로 재면 `막자갈`(뒤채움 재료)이 배합 `자갈` 로 +# 오탐된다 — 개발 중 실제로 걸렸던 자리다. +MIX_COMPONENTS = frozenset( + {"시멘트", "모래", "자갈", "친모래", "친자갈", "잔골재", "굵은골재", "부순돌"} +) + + +@dataclass(slots=True) +class Component: + """전개 결과 한 성분. `basis` 는 어떤 식으로 나왔는지 사람이 읽는 근거다.""" + + name: str + unit: str + amount: float + destination: str + basis: str = "" + # ⚠ 값이 어디서 왔나 — `derived`(치수에서 식으로) / `observed`(실무 관측 원단위표). + # 두 근거가 한 표에 섞이므로 줄마다 단다. 안 적으면 나중에 못 되짚는다. + basis_kind: str = BASIS_DERIVED + source: str = "" + #: 규격 — 같은 이름이라도 단가가 갈리는 값(채움 콘크리트 강도 등). 받는 쪽이 읽는다. + spec: str = "" + + +@dataclass(slots=True) +class StructureQuantity: + """구조물 하나의 원단위 전개 결과.""" + + structure_id: str | None + type_id: str + name: str + length_m: float = 0.0 + height_m: float = 0.0 + # 측점 — 내역 줄에 「어디부터 어디까지」를 적으려면 여기서 따라가야 한다(B09 인계). + start_m: float | None = None + end_m: float | None = None + # 저장된 제원 — **형식(반중력식…)처럼 뒤 단계가 봐야 하는 값**이 여기 있다. + # 치수를 다시 쓰라는 뜻이 아니라 **읽으라고** 실어 나른다(치수 정본은 여전히 하나). + options: dict[str, Any] = field(default_factory=dict) + components: list[Component] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + #: 내역 줄이 설 **단위와 그 단위로 센 수량**. 관측 원단위가 「개소당」·「㎡당」인 + #: 종류는 연장(m)으로 세면 축이 어긋난다(2026-09-08 ㉕ 실증). + #: 비어 있으면 종전대로 「m · 연장」으로 선다. + billing_unit: str = "" + billing_quantity: float = 0.0 + #: 구조물이 놓인 자리의 지반 갈래(`soil`·`ripping_rock`·`blasting_rock`)와 그 판정 근거. + #: 품셈 9-13 구조물터파기의 **토질 축**이 이 값으로 갈린다. 못 가르면 `None` 이다. + ground_type: str | None = None + ground_type_basis: str = "" + #: 양식으로 성분을 세웠으면 그 양식 이름(PLAN 3장 ④-2). 비면 지금 전개 값. + library_item: str = "" + + +def _num(value: Any, fallback: float = 0.0) -> float: + return float(value) if isinstance(value, (int, float)) else fallback diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py new file mode 100644 index 00000000..8a981f6d --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Masonry.py @@ -0,0 +1,523 @@ +"""구조물 원단위 — **돌쌓기·큰돌쌓기 전개식**(버림·터파기 줄 포함) · 2026-09-14 가름. + +⚠ 식을 발명하지 않음 — 실무 정본 식을 옮긴 자리(`UnitQuantity` 머리글). 계수·판정은 `_StoneSpec`. +⚠ 터파기·되메우기·잔토는 **토공으로 가는 줄**(`DESTINATION`) — 내역 줄로 두 번 세지 않음. +""" + +from __future__ import annotations + +import math +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import DESTINATION, Component, _num +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_StoneSpec import ( + BLINDING_THICKNESS_M, + STONE_BACK_LENGTH_TABLE, + STONE_BACKFILL_VERIFIED_HEIGHT_M, + STONE_BACKFILL_WIDTH_M, + STONE_KIND_OPTION, + STONE_MASONRY, + STONE_PACKING_RATIO, + STONE_PILE_BASIS, + _back_length, + back_length_default_note, + face_slope_ratio, + fill_concrete_mpa, + is_collected_stone, + load_stone_kind_table, + stone_coefficients, + stone_weight_per_m2, + wall_thickness, + wants_blinding, + weep_hole_spec, +) +from common_util.common_util_excavation import ( + BASIS_WALL, + WALL_BLINDING_DEPTH_M, + WALL_FOUNDATION_DEPTH_M, + WALL_TRENCH_CLEARANCE_M, + wall_trench_area_m2, + wall_trench_width_m, +) + + +def _blinding_component( + base_width_m: float, + length_m: float, + options: dict[str, Any], + width_basis: str = "", +): + """버림 콘크리트 한 줄. 넣지 않기로 했으면 `None`. + + 폭은 **잡석다짐 폭**(KCS 34 50 05)이고, 그 값은 확정 ⑪ 로 **구조물 하단 길이**다. + 부르는 쪽이 그 폭과 까닭(`width_basis`)을 함께 넘긴다. + """ + if not wants_blinding(options) or base_width_m <= 0 or length_m <= 0: + return None + basis = ( + f"기초 폭 {base_width_m:.2f}m × 연장 × 두께 {BLINDING_THICKNESS_M:.2f}m" + " · 두께는 KDS 44 90 00(100㎜) · 폭은 잡석다짐 폭(KCS 34 50 05)" + ) + if width_basis: + basis = f"{basis} = {width_basis}" + return Component( + "버림콘크리트", + "㎥", + base_width_m * length_m * BLINDING_THICKNESS_M, + DESTINATION["버림콘크리트"], + basis, + ) + + +#: 큰돌쌓기(품셈 13-6) 직경 갈래 — **저장 제원 `stone_cm` 과 글자까지 같다**(레지스트리 확인). +#: ⚠ 돌쌓기(13-4)의 **뒷길이** 축과 섞지 않는다. 앞서 섞여 있어 직경 60~80 짜리가 +#: 「뒷길이 45㎝」 계수로 돌던 자리다. +BOULDER_DIAMETERS = ("40~60", "60~80", "80~100") + +#: 큰돌쌓기 전개 상수. **재료 원단위는 품셈에 없다** — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. +#: 그래서 여기서 내는 것은 **면적과 터파기 계열까지**이고 큰돌 자체는 미확보로 둔다. +BOULDER_MASONRY = { + # ⚠ 검산 참고값 — 곱하지 않는다. 까닭은 `STONE_MASONRY` 의 같은 칸 주석을 볼 것. + # 큰돌쌓기는 전면 기울기가 「1:0.3 **이상**」이라 특히 하드코딩하면 안 된다. + "sheet_check_factor_at_0_3": 1.04, + # ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다 + # (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다). + # 여기서 다시 두면 그림과 물량이 갈린다. +} + + +def _boulder_diameter(options: dict[str, Any]) -> str | None: + """저장 제원의 직경 갈래. 표에 없는 값이면 **지어내지 않고 `None`**.""" + raw = options.get("stone_cm") + text = str(raw).strip() if raw is not None else "" + return text if text in BOULDER_DIAMETERS else None + + +def boulder_masonry( + height_m: float, length_m: float, options: dict[str, Any] +) -> tuple[list[Component], list[str]]: + """큰돌쌓기(품셈 13-6) 전개 — **치수로 낼 수 있는 것까지만** 낸다. + + ⚠ 재료 원단위가 품셈에 없다 — 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」. + 돌쌓기(13-4)처럼 「뒷길이별 돌중량·고임돌」 표가 **없으므로** 큰돌·고임돌·막자갈을 + 내지 않는다. 대신 **왜 못 내는지**를 알림으로 남긴다. + + ⚠ 고임돌·채움콘크리트 품은 **품에 포함**돼 있다(13-6-1 [주]①·13-6-2 [주]①) — + 따로 세우면 이중계상이다. + + ⚠ 뒤채움(조약돌)은 **13-3 을 적용**한다(13-6 [주]⑧) — 이 표에서 찾지 않는다. + 두께는 13-4-3 [주]⑨ 를 준용하는데 **직고별 범위값**(상부 20~40㎝ / 하부 30~140㎝)이라 + 한 값으로 못 정한다. 미확보로 둔다. + """ + notes: list[str] = [] + if height_m <= 0 or length_m <= 0: + return [], ["높이·연장이 없어 전개하지 않음"] + + diameter = _boulder_diameter(options) + if diameter is None: + return [], [ + "큰돌쌓기 돌 직경이 아직 입력되지 않았습니다 — 구조물 상세 입력에서 " + f"{' · '.join(BOULDER_DIAMETERS)}㎝ 중 하나를 고르면 값이 섭니다" + ] + + # ⚠ 큰돌쌓기는 **표준경사표 대상이 아니다** — 교본이 「1:0.3 **이상**」으로만 두었다. + # 저장 제원에 값이 있으면 그것을 쓰고, 없으면 하한 0.3 으로 둔다. + slope_ratio = _num(options.get("face_slope_ratio"), 0.3) + face_area = height_m * length_m + # 기울기 몫은 **한 번만** — 13-4 와 같은 자리다(㉘). + masonry_area = face_area * math.hypot(1.0, slope_ratio) + + components = [ + Component( + "큰돌쌓기", + "㎡", + masonry_area, + DESTINATION["돌쌓기"], + f"정면적 × √(1+{slope_ratio}²) · 직경 {diameter}㎝ (품셈 13-6)", + ) + ] + + # 터파기·되메우기·잔토 — 치수에서 나온다. 두께는 **직경 갈래의 위 끝**을 벽 두께로 본다. + # ⚠ 품셈에 큰돌쌓기 터파기 폭 규정이 없어 **돌쌓기와 같은 방식**(벽 두께 + 여유 0.2m)으로 + # 낸다. 근거 문구에 그 사실을 적어 되짚을 수 있게 한다. + upper_cm = float(str(diameter).split("~")[-1]) + thickness = upper_cm / 100.0 + # 버림 콘크리트 — 돌쌓기와 같은 자리(사용자 확정 ⑭). 빼려면 저장 제원에서 「안 넣음」. + blinding = _blinding_component( + thickness, + length_m, + options, + f"하단 길이(직경 위끝) {thickness:.2f}m — 확정 ⑪ 준용", + ) + if blinding is not None: + components.append(blinding) + components.extend( + _trench_components( + height_m, + length_m, + thickness, + options, + notes, + "⚠ 큰돌쌓기 터파기 폭 규정이 품셈에 없어 돌쌓기 방식을 준용", + ) + ) + + notes.append( + "재료(큰돌) 원단위 미확보 — 품셈 13-6 [주]⑦ 「재료량은 설계수량을 적용한다」라 " + "돌쌓기(13-4)의 뒷길이별 돌중량 표에 해당하는 것이 없음" + ) + notes.append("고임돌·채움콘크리트는 **품에 포함**(13-6 [주]①) — 따로 세우지 않음") + notes.append( + "뒤채움(조약돌)은 13-3 적용(13-6 [주]⑧). 두께는 13-4-3 [주]⑨ 준용인데 " + "직고별 범위값(상부 20~40㎝ / 하부 30~140㎝)이라 한 값으로 못 정함 — 미확보" + ) + # ⚠ **채워져 있으면 그 사유를 붙이지 않는다**(2026-09-09). `bond` 는 레지스트리에 이미 + # 있는 칸인데 조건 없이 붙이고 있어, 값을 고른 뒤에도 「못 고름」이 뜨고 있었다. + # ⚠ 얻는 것은 **노무 품 갈래뿐**이고 재료 원단위는 13-6 [주]⑦ 때문에 여전히 안 선다 — + # 그래도 고치는 까닭은 **사유가 거짓이면 사유 칸 전체를 못 믿게 되기 때문**이다. + if not str(options.get("bond") or "").strip(): + notes.append("⚠ 메/찰 구분이 저장 제원에 없어 13-6-1(메)·13-6-2(찰) 중 어느 쪽인지 못 고름") + return components, notes + + +def _trench_components( + height_m: float, + length_m: float, + thickness: float, + options: dict[str, Any], + notes: list[str], + extra_basis: str = "", +) -> list[Component]: + """터파기·되메우기·잔토 — **공용 단면 함수 한 벌**(`common_util_excavation`). + + ⚠ 여기서 식을 다시 짜지 않는다 — 화면(B06 횡단도)이 그리는 단면과 같은 코드라야 + 그림과 물량이 안 갈린다. 평균두께는 이쪽이 낸 값을 **넘겨 준다**(저쪽은 두께를 모름). + ⚠ 「기초유 / 기초버림」은 정본 탭 제목이고 **기초분 0.45 냐 0.07 이냐**를 가른다. + 안 고른 프로젝트는 **비탈분만** 세우고 사유를 남긴다 — 한쪽으로 찍으면 임의값이 + 금액으로 굳는다(기초분이 통째로 0.45㎥/m 다). + """ + foundation = str(options.get("foundation") or "").strip() + if foundation in ("기초유", "기초버림"): + has_foundation = foundation == "기초유" + area = wall_trench_area_m2(height_m, thickness, has_foundation=has_foundation) + base_depth = WALL_FOUNDATION_DEPTH_M if has_foundation else WALL_BLINDING_DEPTH_M + base_basis = f"기초분({foundation}) + " + else: + width = wall_trench_width_m(thickness) + area = None if width is None else height_m * width + base_depth = 0.0 + base_basis = "" + notes.append( + "⚠ 기초 유/무(기초유·기초버림)를 안 정해 **기초 터파기 몫이 안 섰음** — 비탈분만 세움" + ) + if area is None: + notes.append("⚠ 높이·평균두께가 없어 터파기가 서지 않음") + return [] + excavation = area * length_m + backfill = (base_depth + height_m) * WALL_TRENCH_CLEARANCE_M * length_m + basis = ( + f"{base_basis}H × (평균두께 {thickness:.2f} + {WALL_TRENCH_CLEARANCE_M}) × 연장" + f" · {BASIS_WALL}" + ) + if extra_basis: + basis = f"{basis} · {extra_basis}" + return [ + Component("터파기", "㎥", excavation, DESTINATION["터파기"], basis), + Component( + "되메우기", + "㎥", + backfill, + DESTINATION["되메우기"], + f"(기초깊이 {base_depth:g} + H) × {WALL_TRENCH_CLEARANCE_M} × 연장", + ), + Component( + "잔토처리", + "㎥", + excavation - backfill, + DESTINATION["잔토처리"], + "터파기 − 되메우기", + ), + ] + + +def stone_masonry( + height_m: float, + length_m: float, + options: dict[str, Any], + wet: bool, + face: str | None = None, + face_reason: str = "", +) -> tuple[list[Component], list[str]]: + """돌쌓기(찰/메) 1구간 전개 — 실무 `기슭막이(찰쌓기, H=1.5, 기초무)` 시트의 식. + + 실측 대조(m당, H=1.5, 뒷길이 45㎝, 기울기 1:0.3): + 정면적 1.5 · 비탈면적 1.57 · 평균두께 0.83 · 입적 1.245 + 터파기 1.55 · 되메우기 0.30 · 잔토 1.25 + """ + notes: list[str] = [] + if height_m <= 0 or length_m <= 0: + return [], ["높이·연장이 없어 전개하지 않음"] + + back_cm = _back_length(options) + if back_cm not in STONE_BACK_LENGTH_TABLE: + # ⚠ 접지 않는다 — 다른 규격 계수가 조용히 도는 것보다 「없다」가 낫다. + return [], [ + f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 물량이 서지 않습니다" + ] + table = dict(STONE_BACK_LENGTH_TABLE[back_cm]) + default_note = back_length_default_note(options, back_cm) + if default_note: + notes.append(default_note) + # ⚠ **돌 종류로 계수가 갈린다** (품셈 13-4-3 · 13-4-4 [주]① · 교본 7-3). + # 안 고르면 종전 값(건설품셈 참고자료)으로 돌되 그 사실을 알린다 — 값을 못 낸다고 + # 멈추면 이미 저장된 프로젝트가 통째로 빈다. + picked, kind_note = stone_coefficients(options, back_cm) + if kind_note: + notes.append(kind_note) + # ⚠ **고른 종류의 빈 칸은 빈 칸으로 덮는다.** `None` 이라고 안 덮으면 종전 값(깬돌 계열)이 + # 남아 「야면석 75㎝」처럼 **원문에 「-」인 칸에 값이 서는** 일이 생긴다(만들다 잡음). + if picked.get("kind"): + for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + table[key] = picked.get(key) + else: + for key in ("wedge_stone_m3_per_m2", "fill_concrete_m3_per_m2"): + if picked.get(key) is not None: + table[key] = picked[key] + # ⚠ **교본의 「뒤채움 몫」(깬돌·잡석 1/2 · 야면석 1/3)은 이제 막자갈 밑수가 아니다** — + # ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로 + # 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다. + kind_label = str(picked.get("kind") or "") + # ⭐ 2026-09-13 브레인 판정 — **계수 열 고르기와 돌종류는 다른 축.** 「실무 관행」은 계수 열만 + # 바꾸고 종류는 지워선 안 됨(야면석이 「돌」·계산식 무게로 조용히 서던 결함). 돌 규격·무게는 + # **저장 제원의 종류**를 따름 — 아는 종류일 때만(모르는 글은 종전대로 규격 빈칸). + chosen_kind = str(options.get(STONE_KIND_OPTION) or "").strip() + stone_kind_name = ( + chosen_kind if chosen_kind in (load_stone_kind_table().get("kinds") or []) else kind_label + ) + # ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도 + # 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이 + # 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.) + # **기본 0.3 의 근거** — 교본 7-3 돌흙막이 기준: 「돌 찰쌓기 3.0m 이하 **1:0.3** / + # 돌 메쌓기 2.0m 이하 **1:0.3** / 큰돌쌓기 **1:0.3 이상**(전도 방지)」 + # (`지식DB 02_상세설계/구조물/돌쌓기.md §1`, 값은 `data_masonry` 의 `face_slope`). + # 전면 기울기 — 품셈 표준경사표로 자동 판정하고, 저장 제원에 값이 있으면 그것이 이긴다. + slope_ratio, slope_basis = face_slope_ratio( + options, wet=wet, height_m=height_m, face=face, face_reason=face_reason + ) + constants = STONE_MASONRY + + face_area = height_m * length_m # 정면적 + # 돌쌓기 면적 = 정면적 × √(1+n²) — 기울어진 만큼 길어진다. **기울기 몫은 한 번만.** + # 실무 시트의 「정면적 × 1.04」가 바로 이 값이다(1:0.3 에서 1.0440 ≈ 1.04). + masonry_area = face_area * math.hypot(1.0, slope_ratio) + # 벽 두께 — 실무 구조물도 식(확정 2차 ②). **뒷길이가 들어간다.** + # ⚠ 표준도 제원에 상부·하부 두께 칸이 있으면 **그 값이 식을 이긴다**(확정 ② 후반). + back_m = back_cm / 100.0 # 석적(아래)이 뒷길이를 m 로 쓴다 + top_thickness, bottom_thickness, thickness_basis = wall_thickness( + options, back_cm=back_cm, height_m=height_m + ) + thickness = (top_thickness + bottom_thickness) / 2.0 + volume = face_area * thickness # 입적 + + components = [ + Component( + "돌쌓기", + "㎡", + masonry_area, + DESTINATION["돌쌓기"], + f"정면적 × √(1+{slope_ratio:g}²) — 비탈면적 · {slope_basis}", + ), + # 입적 — 정본 계산표 좌측 열의 이름 그대로다(확정 ⑦). 값을 새로 내는 것이 아니라 + # 이미 쓰고 있던 밑수를 화면에 드러내는 줄이다. + Component( + "입적", + "㎥", + volume, + DESTINATION["입적"], + f"정면적 × 평균두께 {thickness:.2f} · {thickness_basis}", + ), + ] + # ⚠ 고임돌 계수가 **원문에서 「-」**인 칸이 있다(견치돌 25·30 · 야면석 75 · 깬돌 25). + # 그 규격에 그 돌을 안 쓴다는 뜻이라 **0 줄을 만들지 않는다** — 0 은 「없음」과 + # 구별이 안 되고, 받는 쪽이 「값이 0 인 자재」로 읽는다. + if table["wedge_stone_m3_per_m2"] is None: + notes.append( + f"고임돌 계수가 품셈 표에 없습니다 — 뒷길이 {back_cm}㎝" + + (f" · {kind_label}" if kind_label else "") + + " 칸이 「-」입니다" + ) + else: + components.append( + Component( + "고임돌", + "㎥", + masonry_area * _num(table["wedge_stone_m3_per_m2"]), + DESTINATION["고임돌"], + f"돌쌓기 × {table['wedge_stone_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" + + (f" · {kind_label}" if kind_label else ""), + ) + ) + + # 석적 — ⭐ 확정 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, stone_kind_name, table["stone_ton_per_m2"] + ) + weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else "" + if not stone_kind_name: + notes.append( + "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — " + "야면석이면 계산식이 안 맞아 관측표로 갈립니다" + ) + if stone_ton is None: + # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). + notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함") + else: + components.append( + Component( + "돌", + "ton", + masonry_area * stone_ton, + DESTINATION["돌"], + weight_basis, + source=weight_source, + spec=stone_kind_name, + ) + ) + + # 막자갈(뒷채움) — ⭐ 확정 5차 작은 것 3 「(상 + 하) ÷ 2 × H」. **뒷채움 폭**이지 벽 두께가 + # 아니다. 옛 식(입적 − 몸통 − 고임돌)에서 갈아탄 자리다. + backfill_top = STONE_BACKFILL_WIDTH_M["top"] + backfill_bottom = STONE_BACKFILL_WIDTH_M["bottom"] + rubble = (backfill_top + backfill_bottom) / 2.0 * height_m * length_m + components.append( + Component( + "막자갈", + "㎥", + rubble, + DESTINATION["막자갈"], + f"(뒷채움 상 {backfill_top:g} + 하 {backfill_bottom:g}) ÷ 2 × H {height_m:g}m" + f" × 연장 {length_m:g}m · 정본 여섯 탭 공통값", + ) + ) + notes.append( + f"막자갈 뒷채움 폭이 정본(`04.구조도(기슭막이).xls`) 값 상 {backfill_top:g} · " + f"하 {backfill_bottom:g}m 붙박이입니다 — 구조물 제원에 뒷채움 폭 칸이 없습니다" + "(소광리는 같은 식에 0.30/0.60 을 씁니다)" + ) + # ⚠ 근거 구간 밖은 **값이 서되 그 사실이 보여야 한다** — 값은 계속 나오므로 사유가 + # 없으면 아무도 못 본다(2026-09-09 그물 침). + if height_m > STONE_BACKFILL_VERIFIED_HEIGHT_M: + notes.append( + f"직고 {height_m:g}m 는 뒷채움 폭 정본 확인 구간" + f"(H {STONE_BACKFILL_VERIFIED_HEIGHT_M:g}m 이하) **밖**입니다 — 붙박이 폭이라 " + "직고가 커져도 두께가 안 늘어 품셈 13-4-4 [주]⑨ 뒤채움 조약돌 두께 범위보다 " + "얇아집니다(그 표는 직고와 함께 두꺼워짐). 값은 정본 식대로 세우되 " + "**높은 벽의 뒷채움 폭은 실무자 확인이 필요합니다**" + ) + + if wet: + mpa, mpa_basis = fill_concrete_mpa(options) + components.append( + Component( + "채움콘크리트", + "㎥", + masonry_area * _num(table["fill_concrete_m3_per_m2"]), + DESTINATION["채움콘크리트"], + # ⚠ 돌 종류로 계수가 갈리는데(깬돌 0.2 · 야면석 0.15) 근거에 종류가 안 + # 적히던 자리다 — 값은 바뀌는데 **왜 바뀌었는지가 안 보였다**(고임돌은 + # 적고 있었다). 2026-09-09 맞춤. + f"돌쌓기 × {table['fill_concrete_m3_per_m2']} ㎥/㎡ (뒷길이 {back_cm}㎝)" + + (f" · {kind_label}" if kind_label else "") + + f" · {mpa_basis}", + spec=f"{mpa}", + ) + ) + components.append( + Component( + "모르터", + "㎥", + masonry_area * constants["mortar_m3_per_m2"], + DESTINATION["모르터"], + f"돌쌓기 × {constants['mortar_m3_per_m2']} ㎥/㎡ (줄눈)", + ) + ) + # ⚠ 여기서 멈춘다 — 모르터를 시멘트·모래로 쪼개지 않는다(㉢). + + # 물구멍 — 벽면적 2㎡당 1개소, 개소당 관 길이 = **평균두께**(관이 벽을 가로지름). + # ⚠ 이것은 **관(파이프) 자재**이지 공제 대상이 아니다. 품셈 1-2-1 이 「공제하지 않는다」고 + # 말하는 물구멍은 **콘크리트 체적에서 뺄 구멍**이고, 여기 값은 그 구멍에 넣는 **관 길이**다. + # ⚠⚠ **㉥ 이중계상** — 품셈 13-6-2·13-7-2 [주]③ 은 제잡비 **윗단** 값에 + # 「물빼기 파이프 설치에 관계되는 노무비, 재료비를 포함한다」고 한다. 그 쪽을 쓰면 + # 이 줄과 겹친다. **우리 선택은 이 줄을 세우고 제잡비는 아랫단(미설치)** 이다 + # (`structure_unit_observed` 의 `double_count_rules`). + # 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인). + # ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면 + # 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다. + # ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 — + # **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식). + # ⭐ 2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = **평균두께**. 정본이 그렇게 잡고 + # 관은 벽을 가로지르므로 이치에도 맞음. 옛 상수 0.5m 는 그만큼 짧게 섰음. + # ⚠ 골막이(`_Revetment`)는 그 정본이 0.5 라 그대로 둠 — 여기는 기슭막이 계열만. + hole_area, hole_dia, hole_basis = weep_hole_spec(options) + components.append( + Component( + "물구멍관", + "m", + masonry_area / hole_area * thickness, + DESTINATION["물구멍관"], + f"돌쌓기 ÷ {hole_area:g}㎡/개소 × 평균두께 {thickness:.2f} m/개소 · {hole_basis}", + spec=f"Ø{hole_dia}", + ) + ) + + # 채집석 — **캐서 쓰는 구조물**의 돌 체적. 사토에서 뺄 밑수이고 **여기서 빼지 않는다.** + # ⚠ 밑수가 확정 5차로 바뀌었다 — 면석 몸통은 이제 **석적**(정면적×뒷길이×0.77)이고 + # 막자갈은 **뒷채움 사다리꼴**이다. 옛 「입적 − 몸통 − 고임돌」 몫이 아니다. + if is_collected_stone(options): + wedge = masonry_area * _num(table["wedge_stone_m3_per_m2"]) # None 이면 0 + collected = max(stone_pile + wedge + rubble, 0.0) + if collected > 0: + components.append( + Component( + "채집석", + "㎥", + collected, + DESTINATION["채집석"], + f"석적 {stone_pile:.3f} + 고임돌 {wedge:.3f} + 막자갈 {rubble:.3f} ㎥" + " · 현장 채집분 · ⚠ 여기서 빼지 않음 — 사토에서 한 번만 뺌", + ) + ) + + # 버림 콘크리트 — 기초 바닥에 까는 얇은 층. 빼려면 저장 제원에서 「안 넣음」으로 둔다. + # 잡석다짐 폭 = **하단 길이**(확정 ⑪) — 벽에서는 하부 두께다. 버림도 같은 폭을 쓴다. + base_width = bottom_thickness + blinding = _blinding_component( + base_width, + length_m, + options, + f"하단 길이(하부 두께) {bottom_thickness:.2f}m — 확정 ⑪ 「기준 구조물에서 유도」", + ) + if blinding is not None: + components.append(blinding) + # ⓘ 기초잡석은 `build_table` 이 버림 뒤에 세움(확정 3차 ② 두께 0.2 · PLAN 10장 「계상함」). + # 옛 「두께가 없어 안 섬」 사유는 걷음 — 줄과 사유가 함께 떠 서로 어긋났음(2026-09-13 화면 실측). + + # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). + components.extend( + _trench_components(height_m, length_m, thickness, options, notes, thickness_basis) + ) + return components, notes diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py index 6fa008f6..47181b73 100644 --- a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_Revetment.py @@ -210,6 +210,7 @@ def erosion_check_dam( STONE_BACK_LENGTH_TABLE, STONE_MASONRY, _back_length, + back_length_default_note, fill_concrete_mpa, stone_weight_per_m2, weep_hole_spec, @@ -240,11 +241,9 @@ def erosion_check_dam( 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}㎝ 로 섰습니다 " - "— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다" - ) + default_note = back_length_default_note(options, back_cm) + if default_note: + notes.append(default_note) back_m = back_cm / 100.0 given = _num(options.get("face_slope_ratio"), 0.0) diff --git a/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py new file mode 100644 index 00000000..05ecf4db --- /dev/null +++ b/B08_Quantity/B08_Quantity_Engine_UnitQuantity_StoneSpec.py @@ -0,0 +1,524 @@ +"""구조물 원단위 — **돌쌓기 제원·계수표·판정** · 2026-09-14 `UnitQuantity` 에서 가름. + +뒷길이 계수표 · 돌 종류 계수 · 돌 무게 · 물구멍 · 채움 강도 · 버림·기초잡석 상수 · 표준경사 판정 · +벽 두께. 성분을 세우지 않음 — 세우는 것은 `_Masonry`(전개식)·`UnitQuantity.build_table`. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import _num + +# ── 계수표 — 식에 박지 않고 여기서 고른다 ───────────────────────────── +# 돌 뒷길이(㎝)별 원단위. 출처: `original/실무문서/_원단위라이브러리_울진소광.md` 「돌뒷길이별 원단위표」. +# ⚠ 60㎝ 돌중량은 원본이 비어 있다 — 지어내지 않고 None 으로 둔다(PLAN 8-8 ㉮). +#: ⚠ **일곱 규격**이다 — 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 를 다 준다. +#: 앞서 네 칸(35·45·55·60)만 들고 25·30 을 35 로, 75 를 60 으로 **접고** 있었다. +#: `stone_ton_per_m2`(돌중량)는 **실무 관측값**이라 그 넷에만 있다 — 없는 칸은 `None`. +STONE_BACK_LENGTH_TABLE: dict[int, dict[str, float | None]] = { + 25: {"fill_concrete_m3_per_m2": 0.11, "wedge_stone_m3_per_m2": None, "stone_ton_per_m2": None}, + 30: {"fill_concrete_m3_per_m2": 0.14, "wedge_stone_m3_per_m2": 0.10, "stone_ton_per_m2": None}, + 35: {"fill_concrete_m3_per_m2": 0.16, "wedge_stone_m3_per_m2": 0.12, "stone_ton_per_m2": 0.575}, + 45: {"fill_concrete_m3_per_m2": 0.20, "wedge_stone_m3_per_m2": 0.15, "stone_ton_per_m2": 0.88}, + 55: {"fill_concrete_m3_per_m2": 0.25, "wedge_stone_m3_per_m2": 0.18, "stone_ton_per_m2": 1.10}, + 60: {"fill_concrete_m3_per_m2": 0.27, "wedge_stone_m3_per_m2": 0.20, "stone_ton_per_m2": None}, + 75: {"fill_concrete_m3_per_m2": 0.34, "wedge_stone_m3_per_m2": 0.25, "stone_ton_per_m2": None}, +} +DEFAULT_BACK_LENGTH_CM = 45 + +#: ⑤ 돌 중량이 어디서 온 값인가 (2026-09-08 사용자 확정 2차 ⑤ 「DB 확인 → 없으면 현재 유지, +#: 나중에 프로그램에서 확인할 수 있게 근거를 남길 것」). +#: ⚠ **품셈·교본에는 돌중량표가 없다** — 지식DB 전수 확인 결과 이 값이 나오는 곳은 +#: 실무 원단위 라이브러리 한 곳뿐이다(기술문서에는 그 라이브러리를 인용한 줄만 있음). +#: 그래서 값을 바꾸지 않고 **어디서 온 값인지**를 줄마다 달아 화면에서 되짚게 한다. +STONE_WEIGHT_SOURCE = "uljin_library" +STONE_WEIGHT_BASIS = ( + "⚠ 실무 관측값 — 울진 소광 원단위 라이브러리 「돌뒷길이별 원단위표」(35·45·55㎝만 있음)." + " 품셈·교본에는 돌중량표가 없음" +) + +#: ⭐ 사용자 확정 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_masonry` 주석). +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} + +#: ⚠⚠ 정본 전수 확인이 **H=1.0~3.0** 까지다 — 그보다 높은 벽은 **근거가 없다.** +#: 그리고 그 자리에서 품셈 13-4-4 [주]⑨ <뒤채움 조약돌 두께> 범위와 **갈린다**: +#: H 3.0 두께 0.359 (품셈 0.33~0.58 안) · H 3.5 0.354 (품셈 0.40~0.70 **아래**) +#: 까닭은 이 식의 두께(= 막자갈 ÷ 비탈면적)가 `0.375 ÷ √(1+경사²)` 로 거의 붙박이인데, +#: 품셈 표는 직고와 함께 두꺼워지기 때문이다 — **방향이 반대다.** +#: ⚠ 교본 7-3 은 「찰 3.0m 이하 · 메 2.0m 이하」로 두지만 **코드가 높이를 막지 않고** +#: 표준경사표(13-4-4 [주]⑪)는 직고 7m 까지 칸을 준다 — 즉 **높은 벽이 실제로 설 수 있다.** +#: 값을 눅이거나 막지 않고 **그 사실을 사유로 드러낸다**(임의 확정 금지). +STONE_BACKFILL_VERIFIED_HEIGHT_M = 3.0 + +#: 석적 — ⭐ 확정 5차 작은 것 4. **정본에는 없고 소광리에만 있는 줄**이라 사유에 적는다. +#: 소광리 식 `H × ℓ3 × 0.77`(면적 × 뒷길이 × 채움률) 그대로. +STONE_PILE_BASIS = "⚠ 정본에 없는 줄 — 소광리 시트에만 있음(정면적 × 뒷길이 × 0.77)" + + +#: 돌 종류별 계수표 — 품셈 13-4-3·13-4-4 [주]① · 교본 7-3. +#: ⚠ 지금까지 **건설품셈 참고자료 한 벌**(돌 종류로 안 갈리는 표)로만 돌고 있었다. +#: 그 값이 「깬돌」 계열이라, **자재로는 야면석을 내면서 계수는 깬돌**을 쓰는 어긋남이 +#: 있었다(2026-09-08 지식DB 대조). 랩탑 창이 `stone_kind` 칸을 만들어 축이 생겼다. +STONE_KIND_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +STONE_KIND_PREFIX = "stone_kind_" +STONE_KIND_OPTION = "stone_kind" + + +@lru_cache(maxsize=1) +def load_stone_kind_table() -> dict[str, Any]: + """돌 종류별 계수표. 파일이 없으면 **빈 표** — 그러면 종전 값으로 돈다.""" + folder = STONE_KIND_DIR + if not folder.is_dir(): + return {} + files = sorted(folder.glob(STONE_KIND_PREFIX + "*.json")) + if not files: + return {} + try: + return json.loads(files[-1].read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +#: ⑨ 계수를 **어느 열로 볼 것인가** (2026-09-08 사용자 확정 2차 ⑨). +#: 기본은 **품셈 열**(야면석 고임돌 0.11 · 채움 0.15)이고, 「실무 관행」으로 두면 +#: 건설품셈 참고자료 한 벌(깬돌 계열 0.15 · 0.20)로 덮어쓴다. +#: ⚠ **빈 칸은 「안 정함」**이고 그때 기본(품셈)으로 선다 — 등록부 default 는 비어 있다. +#: 기본값을 등록부가 아니라 여기가 갖는 것은 `face_slope_ratio` 와 같은 결이다. +COEFF_BASIS_KEY = "stone_coeff_basis" +COEFF_BASIS_PRACTICE = {"실무 관행", "실무관행", "practice", "관행"} + + +def wants_practice_coefficients(options: dict[str, Any]) -> bool: + """계수를 실무 관행 열로 볼 것인가. 빈 칸이면 **아니오**(= 품셈 열).""" + return str(options.get(COEFF_BASIS_KEY) or "").strip() in COEFF_BASIS_PRACTICE + + +def stone_coefficients(options: dict[str, Any], back_cm: int) -> tuple[dict[str, Any], str]: + """(계수 한 벌, 알림). 돌 종류를 안 고르면 **종전 값**으로 돌되 그 사실을 알린다. + + ⚠ 값을 못 낸다고 멈추지 않는다 — 이미 저장된 프로젝트가 통째로 비어 버린다. + ⚠ 표에 「-」(그 규격에 그 돌을 안 씀)면 **지어내지 않고** 사유를 낸다. + """ + table = load_stone_kind_table() + key = str(back_cm) + kind = str(options.get(STONE_KIND_OPTION) or "").strip() + if not table: + return {}, "" + if key not in [str(x) for x in (table.get("back_lengths_cm") or [])]: + return {}, (f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다") + practice = wants_practice_coefficients(options) + if not kind or practice: + fallback = table.get("fallback") or {} + message = ( + f"「{kind}」이지만 계수를 **실무 관행 열**(건설품셈 참고자료)로 덮어썼습니다 —" + " 기본은 품셈 열입니다(확정 ⑨)" + if practice and kind + else str(fallback.get("message") or "") + ) + return { + "wedge_stone_m3_per_m2": (fallback.get("wedge_stone_m3_per_m2") or {}).get(key), + "fill_concrete_m3_per_m2": (fallback.get("fill_concrete_m3_per_m2") or {}).get(key), + "backfill_ratio": fallback.get("backfill_ratio_of_back_length"), + "kind": "", + }, message + if kind not in (table.get("kinds") or []): + return {}, f"「{kind}」은(는) 아는 돌 종류가 아니라 계수를 못 골랐습니다" + wedge = ((table.get("wedge_stone_m3_per_m2") or {}).get(kind) or {}).get(key) + fill = ((table.get("fill_concrete_m3_per_m2") or {}).get(kind) or {}).get(key) + ratio = (table.get("backfill_ratio_of_back_length") or {}).get(kind) + note = "" + if wedge is None: + note = f"품셈 13-4-3 에 「{kind} · 뒷길이 {back_cm}㎝」 칸이 비어 있습니다 — 그 규격에 그 돌을 쓰지 않습니다" + return { + "wedge_stone_m3_per_m2": wedge, + "fill_concrete_m3_per_m2": fill, + "backfill_ratio": ratio, + "kind": kind, + }, note + + +# 돌쌓기 전개식의 상수 — 실무 수식에 박혀 있던 값을 뺀 것. +#: ⚠⚠ **기슭막이 계열(돌쌓기 찰·메) 전용이다. 다른 구조물로 넓히지 말 것**(2026-09-09). +#: 골막이는 **더하는 몫이 다르다** — 소광리 숨김탭 「골막이(찰)(치수조서연결)」 C26: +#: 골막이 평균두께 = {(ℓ3 + 0.1×H) + (ℓ3 + 0.4×H)} ÷ 2 +#: 기슭막이 상부 = ℓ3 + 0.30 · 하부 = 상부 + 0.30(H − 1) +#: ⚠⚠ **둘 다 뒷길이(ℓ3) 기반이다** — C26 의 「0.45」는 상수가 아니라 `P6/100`(뒷길이 45㎝)을 +#: 읽은 값이었다(2026-09-09 랩탑 보조가 셀 참조로 확인). 앞서 여기 「0.45 + 0.1H」로 적어 +#: **상수처럼 보이게** 두었는데, 그렇게 굳으면 **뒷길이를 바꿔도 골막이 값이 안 움직이는** +#: 결함이 조용히 남는다. +#: ⚠ 상수만 보고 골막이에 가져다 쓰면 **값이 나오므로 아무 시험도 안 잡는다.** +#: ⚠ 골막이는 그 밖에도 셋이 더 다르다 — 밑수가 **돌쌓기 + 돌붙임**(기슭막이는 돌쌓기만) · +#: 단위가 **개소당**(기슭막이는 m당) · 정면적이 **사다리꼴**. +STONE_MASONRY = { + # ⚠ **곱하는 값이 아니라 검산 참고값이다** (2026-09-08 ㉘ 에서 고침). + # 실무 시트의 「돌쌓기 = 정면적 × 1.04」에서 그 1.04 가 **곧 기울기 몫**이다 + # (1:0.3 → √(1+0.3²) = 1.0440 ≈ 1.04). 시트가 반올림해 적은 것을 우리가 + # **별도 계수로 오해해 `hypot` 위에 또 곱하고 있었다** — 면적이 4 % 부풀었고 + # 그 면적이 고임돌·야면석·채움콘크리트·모르터·물구멍 **전부의 밑수**였다. + # ⚠ 하드코딩하면 안 되는 값이다 — 큰돌쌓기는 「1:0.3 **이상**」이라 기울기가 + # 바뀔 수 있고, 그때 1.04 는 틀린 값이 되지만 `hypot` 은 따라간다. + "sheet_check_factor_at_0_3": 1.04, # 1:0.3 에서 시트값과 맞는지 대조하는 자리 + # ⭐ 2026-09-09 확정 2차 ② — **실무 구조물도 식**으로 갈았음(랩탑 보조가 소광리 원본 + # 네 탭에서 찾아 실측과 소수점까지 대조). + # 상부 두께 = 뒷길이(ℓ3) + 0.30 + # 하부 두께 = 상부 + 0.30 × (H − 1.0) + # ⚠ **옛 식에는 뒷길이가 없었다**(0.45 + 0.10·H / 0.45 + 0.40·H) — 뒷길이를 35 로 + # 바꿔도 두께가 안 바뀌어 돌 부피·막자갈·터파기가 다 같은 값으로 섰다. + # ⚠ 정본 xls 의 「상부 0.75 · 하부 0.90」은 **H=1.5 규칙값을 다른 높이 탭에 복사**한 + # 흔적이라 그대로 쓰면 H=1.5 밖에서 틀린다. + # ⚠ 돌조공(H=0.5 · 0.55/0.65)은 이 규칙 밖 — 다른 시설이라 여기서 다루지 않는다. + "thickness_top_add_m": 0.30, # 상부 = 뒷길이 + 0.30 + "thickness_slope_per_m": 0.30, # 하부 = 상부 + 0.30 × (H − 1.0) + "thickness_height_base_m": 1.0, # 그 식의 기준 높이 + # ⚠ 미결 — 법은 「2~3 ㎡당 1개소 **이상**」(구조물_수량.md §물구멍)이고 2.0 은 **실무 관측값**이다. + # 범위의 한쪽 끝을 쓰는 것이라 사용자 확정 전까지 잠정이다. 식이 아니라 여기 있으니 갈아끼우면 된다. + "weep_hole_area_m2": 2.0, # 물구멍 1개소당 벽면적 + # ⚠ **구조물마다 다른 값이다** — 실무 관측: 반중력식 옹벽 0.32 m/m · 돌기슭막이 0.39 m · + # 돌골막이 0.5 m/개소(2026-09-08 랩탑 보조). 여기 0.5 하나로 돌고 있으니 그 사실을 + # 적어 둔다 — 관측 원단위가 있는 종류는 그 표가 이기고, 없는 종류만 이 값으로 선다. + # ⓘ 2026-09-13 — 돌쌓기(기슭막이)는 **평균두께**로 갈음(브레인 판정). + # 이 값은 골막이 정본용으로만 남음. + "weep_hole_length_m": 0.5, # 물구멍관 1개소당 관 길이 — 골막이 + "mortar_m3_per_m2": 0.009, # 줄눈 모르터 (찰쌓기만) + # ⚠ 터파기 여유폭·되메우기 두께는 **공용 한 벌**로 옮겼다 + # (`common_util/common_util_excavation.py` — 화면 B06 과 짝인 TS 가 있다). + # 여기서 다시 두면 그림과 물량이 갈린다. +} + +# 성분이 어디로 가는가 — 이중계상을 막는 표시. +# `earthwork` = 토공 대분류로 합산(울진 토적집계 D12~D14 실증) +# `material` = 자재총괄로 감(할증은 거기서 한 번만) +# `unit_price` = 일위대가 재료비 구성으로 감(B09 가 배합을 분해) +#: 버림 콘크리트 — **빠뜨리고 있던 줄**이다(2026-09-09 사용자 확정 ⑭). +#: 두께 근거: KDS 44 90 00 도로암거구조설계기준 「기초시공시 기초지반 다짐을 시행하고 +#: 구조물 시공이 원활하도록 **100 mm 두께의 버림콘크리트**를 타설하도록 한다」. +#: 폭 근거: KCS 34 50 05 「버림 콘크리트의 두께는 설계도서에 따르며, **폭은 잡석다짐의 +#: 폭과 동일**하게 한다」. +#: ⭐ 그 잡석다짐 폭이 **정해졌다** — 「기준 구조물에서 유도, 같은 값을 공유」 +#: (2026-09-08 사용자 확정 2차 ⑪ 「기슭막이면 **하단 길이** × 종단방향 폭 × 높이」). +#: ⇒ 벽에서 **하단 길이 = 하부 두께**다. 종전의 「터파기 폭 잠정」을 그 값으로 바꾼다. +#: ⚠ **기초잡석(품셈 12-25) 물량은 아직 못 센다** — 폭은 이제 있으나 **두께가 어느 +#: 원문에도 없다**(품셈은 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 +#: **다른 구조물 값을 옮겨 쓰지 않고** 사유로 드러낸다. +#: 돌을 **사 오나 캐나** — 기본은 「캔다」(2026-09-09 사용자 확정 ②). +#: 법이 그쪽을 권한다 — 별표2 「석축 등에 필요한 야면석 등은 **가급적 현장에서 +#: 채취·사용**하도록 운반거리를 조사한다」. 실무 견적 다섯 권에도 야면석 **구입 단가가 +#: 0건**이고 울진 일위대가는 「구입/채집」 두 벌을 갖고 있다. +#: ⚠ 구조물마다 바꿀 수 있다 — 저장 제원에 「구입」이라 적힌 구조물만 공제에서 빠진다. +STONE_SUPPLY_KEYS = ("stone_supply", "stone_source") +STONE_SUPPLY_PURCHASED = {"구입", "구입품", "사서", "purchase", "purchased", "buy"} + +#: ⚠⚠ **채집석 공제는 사토에서 한 번만 뺀다** (2026-09-09 세 창 확정 · 랩탑 메인의 통로에도 +#: 같은 문장이 박혀 있다 — 두 곳이 같은 말이라야 나중에 누가 봐도 안 갈린다). +#: +#: 채집석 공제는 사토에서 한 번만 뺀다. +#: B08 은 소요량(collected_stone_deduction_m3, ㎥ 양수)을 내기만 하고 공제하지 않으며, +#: 빼는 자리는 유토곡선의 사토뿐이다 — +#: 실어 내는 몫(spoil_m3 − natural_spoil_m3)에서 먼저 빼고 모자라면 자연방토에서 뺀다. +#: +#: ⚠ **부호를 넘기지 않는다.** 실무 시트가 `−274.66` 으로 적혀 있어 그대로 넘기면 두 번 +#: 뒤집힌다. 우리는 **양수**로 주고, 빼는 것은 받는 쪽이 한다. +COLLECTED_STONE_KEY = "collected_stone_deduction_m3" + +#: ⑩ 채움 콘크리트 강도 (2026-09-08 사용자 확정 2차 ⑩ 「기본 210, 고를 수 있게」). +#: ⚠ **빈 칸은 「안 정함」**이고 그때 210 으로 선다 — 등록부 default 는 비어 있다. +#: 180 은 국가기준 하한(돌쌓기 전용 값)이라 고르면 그 값이 이긴다. +FILL_CONCRETE_MPA_KEY = "fill_concrete_mpa" +FILL_CONCRETE_MPA_DEFAULT = "210" +FILL_CONCRETE_MPA_CHOICES = ("180", "210") + + +def fill_concrete_mpa(options: dict[str, Any]) -> tuple[str, str]: + """(강도, 근거). 안 정했으면 기본 210 이고 그 사실이 근거에 적힌다.""" + value = str(options.get(FILL_CONCRETE_MPA_KEY) or "").strip() + if value in FILL_CONCRETE_MPA_CHOICES: + return value, f"강도 {value} — 저장 제원에서 고른 값" + if value: + return ( + FILL_CONCRETE_MPA_DEFAULT, + f"강도 {FILL_CONCRETE_MPA_DEFAULT} — ⚠ 저장값 「{value}」은 180·210 중에" + " 없어 기본으로 섬", + ) + return ( + FILL_CONCRETE_MPA_DEFAULT, + f"강도 {FILL_CONCRETE_MPA_DEFAULT} — 안 정해 기본값(확정 ⑩)", + ) + + +BLINDING_THICKNESS_M = 0.10 + +#: 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」. +#: ⚠ 품셈 12-25 는 **㎥당 품만** 주고 두께를 정하지 않는다(전 출처 소진). 폭은 **버림 폭과 +#: 같고**(KCS 34 50 05 「버림 콘크리트의 폭은 잡석다짐의 폭과 동일」) 그 폭은 확정 ⑪ 로 +#: 하단 길이다. ⇒ **기초잡석 = 버림 × (잡석두께 ÷ 버림두께)** 로 나온다 — 같은 폭·연장이라 +#: 두께 비만 곱하면 된다. 관측 원단위로 오는 구조물(옹벽)도 같은 식으로 선다 +#: (버림 0.15㎥/m ÷ 0.1 = 폭 1.5m ⇒ 잡석 0.30㎥/m — 확정 3차 ② 의 그 값). +RUBBLE_BASE_THICKNESS_M = 0.2 + + +#: 버림을 뺄 수 있는 칸 — 「기본은 넣고, 빼고 싶으면 뺀다」(사용자 확정 ⑭). +#: 저장 제원에 이 칸이 없으면 **넣는 쪽**이 기본이다. +BLINDING_OPTION_KEYS = ("blinding_concrete", "base_blinding") + + +#: 표준경사표 — 품셈 13-4-4 [주]⑪. 파일이 없으면 종전 기본값(0.3)으로 돈다. +SLOPE_DIR = Path(__file__).resolve().parents[1] / "resources" / "data_masonry" +SLOPE_PREFIX = "masonry_slope_" +#: 사용자가 덮어쓰는 칸 — 값이 있으면 **그 값이 이긴다**(확정 ⑨). +FACE_SLOPE_KEYS = ("face_slope_ratio",) +#: 성토/절토는 **측점 단면유형(`design.section_mode`) + 설치 측**으로 판정한다 +#: (`common_util_structure_face_role` 한 벌). 못 가르는 자리(`both_cut` + 「자동」)만 남아 +#: 그때 쓸 칸을 여기 한 곳에 둔다 — **눅여 쓰는 것이 아니라 종전값으로 서고 사유를 적는다.** +DEFAULT_SLOPE_FACE = "성토" +LEGACY_FACE_SLOPE_RATIO = 0.3 + + +@lru_cache(maxsize=1) +def load_slope_table(path: str | None = None) -> dict[str, Any]: + """표준경사표. 없으면 빈 표 — 그때는 종전 기본값으로 돈다(값을 지어내지 않는다).""" + target = Path(path) if path else None + if target is None: + files = sorted(SLOPE_DIR.glob(SLOPE_PREFIX + "*.json")) if SLOPE_DIR.is_dir() else [] + target = files[-1] if files else None + if target is None or not target.is_file(): + return {} + return json.loads(target.read_text(encoding="utf-8")) + + +def face_slope_ratio( + options: dict[str, Any], + *, + wet: bool, + height_m: float, + face: str | None = None, + face_reason: str = "", + table: dict[str, Any] | None = None, +) -> tuple[float, str]: + """(기울기 1:n 의 n, 근거 문구). + + ⚠ **사용자가 정했으면 그 값이 이긴다**(확정 ⑨). 안 정했으면 품셈 13-4-4 [주]⑪ + <표준경사>에서 **직고 · 메/찰 · 성토/절토** 셋으로 고른다. + ⚠ 자동으로 고른 값을 **저장에 박지 않는다** — 「정한 적 없음」과 「0.3 으로 정함」이 + 구별돼야 한다(오늘 패널 접힘에서 지킨 것과 같은 자리). + ⚠ 경계값은 **앞 칸**이다 — 원문이 `∼3` 이라 직고 3.0m 은 `~3` 칸이다. + """ + for key in FACE_SLOPE_KEYS: + given = _num(options.get(key), 0.0) + if options.get(key) is not None and given > 0: + return given, f"사용자 지정 1:{given:g}" + + found = table if table is not None else load_slope_table() + steps = found.get("steps_m") or [] + bond = "찰쌓기" if wet else "메쌓기" + # ⚠ 성토/절토는 **판정 한 벌**(`common_util_structure_face_role`)이 준다. 못 가르면 + # `None` 이 오는데, **성토로 눅이지 않는다** — 임의값이 금액으로 굳으면 안 된다. + # 그때는 종전값으로 서되 **왜 못 갈랐는지**를 근거에 적는다. + if not face: + why = face_reason or "성절토를 가를 근거 없음" + return ( + LEGACY_FACE_SLOPE_RATIO, + f"⚠ {why} — 표준경사표를 못 골라 종전값 1:{LEGACY_FACE_SLOPE_RATIO} 로 섰음", + ) + row = ((found.get("table") or {}).get(bond) or {}).get(face) + if not row: + return LEGACY_FACE_SLOPE_RATIO, f"표준경사표를 못 읽어 종전값 1:{LEGACY_FACE_SLOPE_RATIO}" + + index = len(steps) + for i, limit in enumerate(steps): + if height_m <= float(limit): + index = i + break + ratio = float(row[min(index, len(row) - 1)]) + label = f"직고 {height_m:g}m" + (f" ≤{steps[index]:g}m" if index < len(steps) else " 7m 초과") + note = f"품셈 13-4-4 [주]⑪ 표준경사 · {bond} {face} · {label} → 1:{ratio:g}" + if face_reason: + note += f" · {face_reason}" + return ratio, note + + +def is_collected_stone(options: dict[str, Any]) -> bool: + """이 구조물의 돌을 **캐서 쓰는가**. 정한 적이 없으면 「캔다」(확정 ②).""" + for key in STONE_SUPPLY_KEYS: + raw = options.get(key) + if raw is None: + continue + if str(raw).strip().lower() in STONE_SUPPLY_PURCHASED: + return False + return True + + +def wants_blinding(options: dict[str, Any]) -> bool: + """버림 콘크리트를 넣을지. **정한 적이 없으면 넣는다**(사용자 확정 ⑭). + + 「안 넣음」·「제외」·`false` 로 적혀 있을 때만 뺀다 — 빈 칸을 「빼기」로 읽으면 + 저장해 둔 적 없는 프로젝트에서 줄이 통째로 사라진다. + """ + for key in BLINDING_OPTION_KEYS: + raw = options.get(key) + if raw is None: + continue + text = str(raw).strip().lower() + if text in {"false", "0", "no", "제외", "안 넣음", "안넣음", "빼기"}: + return False + return True + + +#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). +#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** +#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. +#: 옛 이름도 함께 본다(다른 곳에서 그 이름으로 넣어 줄 수 있다). +BACK_LENGTH_KEYS = ("back_len_cm", "stone_back_length_cm") + + +def _back_length(options: dict[str, Any]) -> int: + """저장된 뒷길이(㎝)를 **그대로** 돌려준다. 안 정했으면 기본 45. + + ⚠ **접지 않는다.** 품셈 13-4-3·13-4-4 [주]① 이 25·30·35·45·55·60·75 **일곱 규격**을 + 다 주므로 접을 까닭이 없다. 그 밖의 값(40 등)은 **계수가 없다고 드러낸다** — + 접으면 다른 규격 계수가 조용히 돈다. + """ + for key in BACK_LENGTH_KEYS: + raw = options.get(key) + if raw is None: + continue + try: + value = int(float(raw)) + except (TypeError, ValueError): + continue + # ⚠ **접지 않는다.** 앞서 「가장 가까운 위 칸」으로 접고 있었는데, 그러면 40㎝ 가 + # 45㎝ 계수로 **조용히** 돌고 999㎝ 도 60㎝ 로 접혔다(2026-09-08 실측). + # 표에 없으면 그 값을 그대로 돌려주고, 계수를 고르는 쪽이 「없다」고 드러낸다. + return value + return DEFAULT_BACK_LENGTH_CM + + +def back_length_default_note(options: dict[str, Any], back_cm: int) -> str | None: + """뒷길이를 안 골라 기본으로 섰을 때의 한 줄 — 표 사유·구조물도 그림이 **같은 말**을 씀.""" + if any(options.get(key) not in (None, "") for key in BACK_LENGTH_KEYS): + return None + return ( + f"⚠ 뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 " + "— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다" + ) + + +#: 벽 두께를 **사용자가 덮어쓰는 칸** — 비우면 실무 구조물도 식으로 돈다(확정 2차 ② 후반 +#: 「사용자가 값을 바꿀 수 있게」). 야면석 계수·채움 강도와 같은 자리다. +WALL_THICKNESS_KEYS: tuple[str, str] = ("thickness_top_m", "thickness_bottom_m") + + +def wall_thickness( + options: dict[str, Any], *, back_cm: float, height_m: float +) -> tuple[float, float, str]: + """(상부 두께 m, 하부 두께 m, 근거 문구) — **사용자가 넣은 값이 식을 이긴다.** + + ⚠ 빈 칸은 「안 정함」이다 — 자동으로 나온 값을 저장에 박지 않는다(`face_slope_ratio` + 와 같은 태도). 상부만 넣으면 하부는 **그 상부 위에서** 식을 이어 간다 — 벽 모양 + (아래로 벌어지는 기울기)을 사용자 값 위에 그대로 얹는 것이 자연스럽다. + """ + + def given(key: str) -> float | None: + raw = options.get(key) + if raw in (None, ""): + return None + try: + value = float(raw) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + constants = STONE_MASONRY + back_m = back_cm / 100.0 + top_given = given(WALL_THICKNESS_KEYS[0]) + top = top_given if top_given is not None else back_m + constants["thickness_top_add_m"] + rise = constants["thickness_slope_per_m"] * max( + height_m - constants["thickness_height_base_m"], 0.0 + ) + bottom_given = given(WALL_THICKNESS_KEYS[1]) + bottom = bottom_given if bottom_given is not None else top + rise + top_basis = ( + f"상부 {top:.2f}(사용자 입력)" + if top_given is not None + else f"상부 {top:.2f}(뒷길이 {back_cm:g}㎝ + {constants['thickness_top_add_m']:g})" + ) + bottom_basis = ( + f"하부 {bottom:.2f}(사용자 입력)" + if bottom_given is not None + else ( + f"하부 {bottom:.2f}(상부 + {constants['thickness_slope_per_m']:g}" + f"×(H−{constants['thickness_height_base_m']:g}))" + ) + ) + return top, bottom, f"실무 구조물도 식 — {top_basis} · {bottom_basis}" diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index fd16990d..2be54fab 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -26,6 +26,7 @@ import { designDocTab } from "./B09_Estimation_UI_Tab_DesignDoc"; import { basisSheetTab } from "./B09_Estimation_UI_Tab_BasisSheet"; import { supplyTab } from "./B09_Estimation_UI_Tab_Supply"; import { baseDataTab } from "./B09_Estimation_UI_Tab_BaseData"; +import { contractTab } from "./B09_Estimation_UI_Tab_Contract"; /** 탭 등록 — 한 줄에 탭 하나. */ const TABS: B09Tab[] = [ @@ -42,6 +43,7 @@ const TABS: B09Tab[] = [ baseDataTab, designDocTab, basisSheetTab, + contractTab, // 랩탑_메인 — 설계 뒤 계약 단계라 맨 끝 ]; export async function renderB09Estimation(root: HTMLElement): Promise { diff --git a/resources/tester/test_b07_standard_figure_reason.py b/resources/tester/test_b07_standard_figure_reason.py index 85d6da8b..6ecb4ee4 100644 --- a/resources/tester/test_b07_standard_figure_reason.py +++ b/resources/tester/test_b07_standard_figure_reason.py @@ -45,9 +45,9 @@ def _sheet(type_id: str, **options) -> dict: } -def test_뒷길이가_없으면_무엇을_고르면_되는지_적는다() -> None: - reason = figure_reason(_sheet("masonry_wet")) - assert reason is not None and "뒷길이" in reason and "섭니다" in reason +def test_뒷길이가_없어도_표처럼_기본값으로_서니_까닭이_없다() -> None: + """2026-09-14 브레인 판정 — 표가 기본 45㎝ 로 서면 그림도 섬(까닭은 정말 못 그리는 장만).""" + assert figure_reason(_sheet("masonry_wet")) is None def test_뒷길이가_있으면_까닭이_없다() -> None: diff --git a/resources/tester/test_b08_structure_figure.py b/resources/tester/test_b08_structure_figure.py index 451e02f4..908677a8 100644 --- a/resources/tester/test_b08_structure_figure.py +++ b/resources/tester/test_b08_structure_figure.py @@ -16,6 +16,10 @@ from B08_Quantity.B08_Quantity_Engine_StructureFigure import ( # noqa: E402 attach_figures, build_figure, ) +from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402 + back_length_default_note, + stone_masonry, +) def _sheet(**options) -> dict: @@ -65,6 +69,34 @@ def test_못_그리는_장은_그림_대신_까닭이_실린다() -> None: } attach_figures(payload) no_back, boulder, drawn = payload["sheets"] - assert no_back["figure"] is None and "뒷길이" in no_back["figure_reason"] + # 뒷길이 미정은 기본값이 있어 **그림이 섬**(2026-09-14 브레인 판정) — 까닭은 직경 축만. + assert no_back["figure_reason"] is None and no_back["figure"] assert boulder["figure"] is None and "직경" in boulder["figure_reason"] assert drawn["figure_reason"] is None and drawn["figure"] + + +def _texts(shapes: list[dict]) -> list[str]: + return [shape["text"] for shape in shapes if shape["kind"] == "text"] + + +def test_기본값으로_그린_장은_표_사유와_같은_말을_적는다() -> None: + options = {"height_m": 2.0} + _rows, table_notes = stone_masonry(2.0, 1.0, options, wet=False) + table_default = [note for note in table_notes if "뒷길이를 안 골라" in note] + assert table_default == [back_length_default_note(options, 45)] + + texts = _texts(build_figure(_sheet())) + assert table_default[0] in texts + assert "뒷길이 ℓ₃ = 45㎝ (기본)" in texts + # 두께도 기본 45 로 — 상부 0.45+0.30 + assert "상부 0.75 m" in texts + + chosen = _texts(build_figure(_sheet(back_len_cm=45))) + assert "뒷길이 ℓ₃ = 45㎝" in chosen and not any("안 골라" in text for text in chosen) + assert back_length_default_note({"back_len_cm": 45}, 45) is None + + +def test_양식_장은_양식이_푼_뒷길이로_그린다() -> None: + sheet = {**_sheet(), "formula_sheet": {"vars": {"L3": 55}}} + texts = _texts(build_figure(sheet)) + assert "뒷길이 ℓ₃ = 55㎝ (기본)" in texts and "상부 0.85 m" in texts