Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-15 02:48:55 +09:00
17 changed files with 517 additions and 40 deletions
@@ -240,6 +240,55 @@
"default": 2.0,
"required": false
},
{
"key": "length_m",
"label": "연장(계류 방향)",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)"
},
{
"key": "wall_thickness_m",
"label": "측벽 두께",
"input": "number",
"unit": "m",
"default": 0.3,
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음",
"required": false,
"phase": "detail"
},
{
"key": "slab_thickness_m",
"label": "상·저판 두께",
"input": "number",
"unit": "m",
"default": 0.25,
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름",
"required": false,
"phase": "detail"
},
{
"key": "haunch_m",
"label": "헌치(모따기) 크기",
"input": "number",
"unit": "m",
"default": 0.2,
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음",
"required": false,
"phase": "detail"
},
{
"key": "blinding_thickness_m",
"label": "기초(버림) 두께",
"input": "number",
"unit": "m",
"default": 0.1,
"default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음",
"required": false,
"phase": "detail"
},
{
"key": "wing_in",
"label": "날개벽(유입)",
+7 -4
View File
@@ -110,8 +110,9 @@ FORD_DEFAULT_WIDTH_M = 10.0
# 지식DB 폭 수치 근거 없음).
FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0
# BOX암거 부재 두께(m) — 지식DB에 암거 부재 두께 기준이 없어 세월교 값을 승계한
# (2026-08-25 사용자 확정). 측벽·상판·저판은 각각 벽 두께·바닥판 두께를 그대로 쓴다.
# BOX암거 부재 두께 — **등록부 칸**(`wall_thickness_m`·`slab_thickness_m`)이 정본이
# (2026-09-15 브레인 판정 ① · 제안값 = 산림과임업기술(임도) 예제 벽 0.3·판 0.25 · KDS 300㎜ 병기).
# 종전 「세월교 값 승계」(2026-08-25 사용자 확정)는 근거 없는 값이라 걷음 — 수량·그림이 한 칸을 읽음.
# ⚠ 교차 참조 ④ 암거 위 복토(m) — 별표2 교량·암거 "복토 시 흙 두께 50㎝ 이상".
BOX_COVER_M = 0.5
@@ -451,8 +452,10 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
values = dict(options or {})
inner_width = _number(values.get("body_width_m"), _number(defaults.get("body_width_m"), 2.0))
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
wall = FORD_WALL_THICKNESS_M
slab = FORD_SLAB_THICKNESS_M
wall = _number(values.get("wall_thickness_m"), _number(defaults.get("wall_thickness_m"), None))
slab = _number(values.get("slab_thickness_m"), _number(defaults.get("slab_thickness_m"), None))
wall = wall if wall and wall > 0 else FORD_WALL_THICKNESS_M # 등록부가 비었을 때만(옛 판)
slab = slab if slab and slab > 0 else FORD_SLAB_THICKNESS_M
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
return {
@@ -379,7 +379,7 @@ export function createBoxControls(deps: FordControlDeps): {
// 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다.
if (patch.body_width_m) {
spec.inner_width_m = patch.body_width_m;
spec.span_m = boxSpanM(patch.body_width_m);
spec.span_m = boxSpanM(patch.body_width_m, spec.wall_thickness_m);
}
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
}
+34 -1
View File
@@ -369,6 +369,36 @@ def _extra_walls_at(extra_walls: dict[float, list[dict]] | None, chainage: float
return extra_walls[best] if abs(best - chainage) <= SECTION_MATCH_TOLERANCE_M else []
#: BOX암거 치수 칸 — 안 적으면 등록부 기본값(제안값)으로 서되 **미확정**(2026-09-14 브레인 판정 ·
#: 2026-09-15 12장 D). 연장은 기본값이 없어 여기 없음 — 비면 전개식이 사유를 냄.
BOX_DIMENSIONS = (
("body_width_m", "본체 폭"),
("body_height_m", "본체 높이"),
("wall_thickness_m", "측벽 두께"),
("slab_thickness_m", "상·저판 두께"),
("haunch_m", "헌치"),
("blinding_thickness_m", "기초(버림) 두께"),
)
UNCONFIRMED_BOX = "BOX암거 치수({filled})가 시설 지점에 없어 기본값으로 섰음 — 적으면 금액이 섬"
def _box_row(base: dict[str, Any], registry: dict[str, Any]) -> dict[str, Any]:
"""BOX암거 줄 — 빈 치수 칸에 등록부 제안값을 채우고 그 사실을 미확정 사유로 붙임."""
options = dict(base["options"])
filled = []
for key, label in BOX_DIMENSIONS:
if _blank(options.get(key)) and registry.get(key) is not None:
options[key] = registry[key]
filled.append(label)
reason = UNCONFIRMED_BOX.format(filled=" · ".join(filled)) if filled else ""
return {
**base,
"options": options,
"unconfirmed": reason,
"notes": [f"{reason}"] if reason else [],
}
def facility_structures(
points: list[dict[str, Any]],
extra_walls: dict[float, list[dict]] | None = None,
@@ -405,8 +435,11 @@ def facility_structures(
"end_m": point.get("end_m") if point.get("end_m") is not None else chainage,
"options": options,
}
if facility == "box_culvert":
rows.append(_box_row(base, defaults(facility)))
continue
if facility not in (FACILITY_PIPE, "revetment"):
rows.append(base) # BOX암거·물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
rows.append(base) # 물넘이·세월교 — 전개식·관측값이 없으면 사유로 섬
continue
legacy = facility == "revetment" # 독립 기슭막이 — 벽 칸 밖의 제원(뒷길이 등)도 벽이 씀
wall_defaults = defaults(facility)
@@ -116,6 +116,8 @@ EXPANDERS = {
"soil_guard": lambda h, l, o, f=None, r="": _soil_guard()(o),
# 바닥막이는 **돌붙임 ㎡당**이라 높이·연장이 아니라 **면적**이 밑수다(정본 「돌붙임L3=…」).
"bed_sill": lambda h, l, o, f=None, r="": _bed_sill()(_num(o.get("area_m2"), 0.0), o),
# BOX암거 — m당 × 연장(산림과임업기술 예제 · 울진 2×2 · 2026-09-15 브레인 12장 D).
"box_culvert": lambda h, l, o, f=None, r="": _box_culvert()(o),
# ⚠⚠ **큰돌쌓기(`boulder_masonry`)를 여기에 두지 않는다** (2026-09-07 발견).
# 큰돌쌓기는 품셈 **13-6** 이고 돌쌓기는 **13-4** 다 — **규격 축이 다르다.**
# 돌쌓기는 **뒷길이**(35·45·55·60㎝), 큰돌쌓기는 **직경**(40~60·60~80·80~100㎝).
@@ -126,6 +128,13 @@ EXPANDERS = {
}
def _box_culvert():
"""BOX암거 전개 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Box import box_culvert
return box_culvert
def _soil_guard():
"""흙막이 사유 함수를 늦게 가져온다."""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Revetment import soil_guard
@@ -162,7 +171,13 @@ def _revetment():
#: 전개식을 일부러 안 두는 종류 — 왜 안 두는지 사람이 읽게 적는다.
EXPANDER_WITHHELD: dict[str, str] = {}
EXPANDER_WITHHELD: dict[str, str] = {
# 2026-09-15 브레인 판정 ⑥ — 찾아봤으나 없음(「아직 안 만듦」과 다름).
"ford_bridge": (
"세월교 수량 근거 없음 — 법령·임도기술교본은 설치 조건만(그림 3-18 은 배치 모식도)"
" · 실무 원본에 세월교 시트·도면 없음 · 관은 배수관 줄로 따로 섬"
),
}
#: 전개식이 **개소당·㎡당**으로 값을 내는 종류의 내역 단위와 수량(2026-09-14 구조물도 그림에서 잡음).
#: ⚠ 안 적으면 「m · 연장」으로 읽혀 — 골막이·바닥막이(점 배치라 연장 0)는 **수량 0 이라 내역에 안 서고**
@@ -171,6 +186,7 @@ EXPANDER_BILLING: dict[str, Any] = {
"erosion_check": lambda options: ("개소", 1.0), # 정본 「골막이(찰)(치수조서연결)」 개소당
"soil_guard": lambda options: ("개소", 1.0), # 정본 「떼흙막이」 개소당(평균 붙박이)
"bed_sill": lambda options: ("", _num(options.get("area_m2"), 0.0)), # 정본 돌붙임 ㎡당
"box_culvert": lambda options: ("m", _num(options.get("length_m"), 0.0)), # 점 배치라 연장 칸
}
@@ -328,6 +344,15 @@ def expand(
return result
def _shoring_of(quantities: Iterable[StructureQuantity]) -> dict[str, Any]:
"""동바리 상태 — 전개식이 동바리 줄을 낸 구조물이 있으면 대상(2026-09-15 BOX암거)."""
status = shoring_status()
found = sorted({q.name for q in quantities if any(c.name == "동바리" for c in q.components)})
if found:
status.update(applicable=True, reason=f"슬래브 받침 동바리(12-20) — {' · '.join(found)}")
return status
def verify_no_mix_components(quantities: Iterable[StructureQuantity]) -> list[str]:
"""⚠ 배합 성분이 산출물에 섞이면 알린다 (㉢ 이중계상 방어).
@@ -612,8 +637,8 @@ def build_table(
"structures": payload_structures,
"formwork_notes": formwork_notes,
"formwork_reuse_missing": formwork_missing,
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다.
"shoring": shoring_status(),
# 동바리 — 대상이 없으면 0 이 아니라 「없음」이라고 말한다(BOX암거 동바리 줄이 서면 대상).
"shoring": _shoring_of(quantities),
# ⚠ 값을 바꾸는 설계 조건인데 우리 제원에 칸이 없는 것 — 화면에 드러낸다.
# 「무엇을 정해야 하는지」만으로는 부족하고 **「정하면 얼마나 달라지는지」**까지.
"pending_choices": (observed.pending_choices or {}).get("items") or [],
@@ -58,6 +58,10 @@ DESTINATION = {
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
"면목": "material",
"": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
# ⭐ 2026-09-15 BOX암거 — 버림 옆면 합판 · 동바리(12-20)·비계(12-19)는 설치 품이라 묶음 조각으로.
"합판거푸집": "unit_price",
"동바리": "unit_price",
"비계": "unit_price",
}
#: ⛔ **갈 곳 기본값 금지**(명세 13장 「빠진 것이 조용해짐」) — 표에 없는 이름은 막고 사유를 냄.
@@ -0,0 +1,125 @@
"""BOX암거 전개식 — **m당 × 연장** (2026-09-15 브레인 12장 D 판정 ①~⑤).
근거 둘 — 구조가 같다
① 산림과임업기술(임도) 5장 「나) 구조물도에 의한 수량산출(예)」 · 그림 5-2-70
Box(2.0×1.5×2련×6.03m) — 구체 = (외곽 − 유수구 + 헌치) × L · 기초 = 폭 × 0.1 × L ·
거푸집 = 외벽·유수구·받침판·귀면 · 동바리 = 유수구 단면 × L · 철근 「상세도에서」(값 없음)
② 실무 울진 기번3 `5. 구조도(기번3).xlsx` 「암거수량집계표(2×2)」 m당 — 레미콘 2.84 ·
버림 0.28 · 유로폼 11.132 · 합판6회 0.2 · 철근 SD40 H13 0.179 + H16 0.157t · 동바리 3.92 ·
비계 18.72
두께 제안값은 등록부 칸(예제 벽 0.3 · 판 0.25 · 헌치 0.2 · 버림 0.1 · KDS 300㎜ 병기).
⚠ 1련만 — 등록부에 련수 칸이 없음(예제 식은 련수로 늘어나나 그림·칸이 1련).
⚠ 철근은 **크기별 식이 없다** — 울진 2×2 관측값만 선다(판정 ②).
⚠ 비계 시종점 두 면은 **개소당 한 번** — 원문은 m당 칸에 넣어 연장을 곱하면 부풀어 오름.
"""
from __future__ import annotations
import math
from typing import Any
from B08_Quantity.B08_Quantity_Engine_UnitQuantity_Base import Component, _num, routed
#: 버림 폭 여유(m, 한쪽) — 예제 5.1 = 외곽 4.9 + 0.1×2 · 울진 2.8 = 외곽 2.6 + 0.1×2 (둘이 같음).
BLINDING_EXTRA_M = 0.1
#: 철근 관측값(kg/m) — 울진 기번3 「암거수량집계표(2×2)」 SD40 · 내공 2.0×2.0 · 벽·판 0.3.
REBAR_OBSERVED_2X2 = (("D13", 179.0), ("D16", 157.0))
REBAR_SIZE_ONLY = (
"철근 — 크기별 식 없음 · 관측값은 2×2 뿐(울진 기번3 구조도 SD40 H13 0.179 + H16 0.157t/m ·"
" 벽·판 0.3) — 이 크기는 철근이 안 섬(2026-09-15 브레인 판정 ②)"
)
SOURCES = (
"산식 = 산림과임업기술(임도) 5장 수량산출 예제(그림 5-2-70) · 실무 울진 기번3 구조도"
" 「암거수량집계표(2×2)」"
)
NOTE_FORMWORK = (
"거푸집 — 본체 유로폼(외벽 · 내벽 헌치 뺀 높이 · 상판 밑면 · 귀면 넷) · 버림 옆면 합판"
" (울진 2×2 모양 · 예제는 합판 3회)"
)
NOTE_SHORING_SCAFFOLD = (
"동바리(유수구 단면 − 헌치)·비계(외벽 양면 + 시종점 두 면)는 **실무 관측 모양**"
"(울진 2×2 「강재 3개월」) — 품셈 12-20 강관동바리 · 12-19 강관비계로 이음 ·"
" 시종점 두 면은 개소당 한 번(원문은 m당 칸)"
)
NOTE_WING_EARTH = (
"날개벽 수량 근거 없음(교본·실무 모두 · 판정 ⑤) — 날개벽 줄 안 섬"
" · 구체 터파기·되메우기는 아직 안 셈"
)
def box_culvert(options: dict[str, Any]) -> tuple[list[Component], list[str]]:
"""BOX암거 한 개소 — (성분, 사유). 연장이 없으면 성분 없이 사유만."""
length = _num(options.get("length_m"))
if length <= 0:
return [], ["BOX암거 연장(계류 방향)이 없어 수량이 안 섬 — 연장 칸을 적을 것(기본값 없음)"]
width, height = _num(options.get("body_width_m")), _num(options.get("body_height_m"))
wall, slab = _num(options.get("wall_thickness_m")), _num(options.get("slab_thickness_m"))
haunch, blinding = _num(options.get("haunch_m")), _num(options.get("blinding_thickness_m"))
if min(width, height, wall, slab) <= 0:
return [], ["BOX암거 본체 폭·높이·두께 중 빈 칸이 있어 수량이 안 섬"]
outer_w, outer_h = width + 2 * wall, height + 2 * slab
chamfer = math.sqrt(2) * haunch
per_m: list[tuple[str, str, float, str, str]] = [
(
"콘크리트",
"",
outer_w * outer_h - width * height + haunch**2 / 2 * 4,
f"{outer_w:g}×{outer_h:g} {width:g}×{height:g} + {haunch:g}²÷2×4",
"",
),
(
"버림콘크리트",
"",
(outer_w + 2 * BLINDING_EXTRA_M) * blinding,
f"({outer_w:g} + {BLINDING_EXTRA_M:g}×2) × {blinding:g}",
"",
),
(
"유로폼",
"",
2 * outer_h + 2 * (height - 2 * haunch) + (width - 2 * haunch) + 4 * chamfer,
f"외벽 {outer_h:g}×2 + 내벽 ({height:g} {haunch:g}×2)×2"
f" + 상판 밑 ({width:g} {haunch:g}×2) + 귀면 √({haunch:g}²+{haunch:g}²)×4",
"",
),
("합판거푸집", "", 2 * blinding, f"버림 옆면 {blinding:g}×2", ""),
(
"동바리",
"",
width * height - 2 * haunch**2,
f"{width:g}×{height:g} {haunch:g}²÷2×4",
"",
),
("비계", "", 2 * outer_h, f"외벽 양면 {outer_h:g}×2", ""),
]
notes: list[str] = [SOURCES, NOTE_FORMWORK, NOTE_SHORING_SCAFFOLD, NOTE_WING_EARTH]
if math.isclose(width, 2.0) and math.isclose(height, 2.0):
per_m += [
("이형철근", "kg", kg, "울진 기번3 구조도 2×2 관측(SD40)", size)
for size, kg in REBAR_OBSERVED_2X2
]
notes.append("철근은 관측값(울진 2×2 · 벽·판 0.3) — 두께를 바꿔도 따라 안 바뀜")
else:
notes.append(REBAR_SIZE_ONLY)
components = [
Component(
name,
unit,
amount * length,
destination,
f"{basis} = m당 {amount:.4g} × 연장 {length:g}m",
spec=spec,
)
for name, unit, amount, basis, spec in per_m
if (destination := routed(name, notes))
]
ends = 2 * outer_w * outer_h
for component in components:
if component.name == "비계":
component.amount += ends
component.basis += (
f" + 시종점 두 면 {outer_w:g}×{outer_h:g}×2 = {ends:.4g}(개소당 한 번)"
)
return components, notes
+9 -6
View File
@@ -211,9 +211,9 @@ export function pipeDiameterM(diameterMm: number | null | undefined): number {
return round((num(diameterMm, 1000.0) ?? 1000.0) / 1000.0, 3);
}
/** BOX암거 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. */
export function boxSpanM(innerWidthM: number): number {
return innerWidthM + 2 * FORD_WALL_THICKNESS_M;
/** BOX암거 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장(두께는 등록부 칸 · 2026-09-15). */
export function boxSpanM(innerWidthM: number, wallThicknessM: number): number {
return innerWidthM + 2 * wallThicknessM;
}
/** 날개벽이 만드는 바닥판 연장량 = 길이 × cos(벌어짐각). 안 세우면 0. */
@@ -351,8 +351,11 @@ function boxSet(
const defaults = registry.box_culvert ?? {};
const innerWidth = num(values.body_width_m, num(defaults.body_width_m, 2.0));
const innerHeight = num(values.body_height_m, num(defaults.body_height_m, 2.0));
const wall = FORD_WALL_THICKNESS_M;
const slab = FORD_SLAB_THICKNESS_M;
// 두께는 등록부 칸이 정본(2026-09-15 브레인 12장 D ①) — 파이썬 `_box_set` 과 같은 폴백.
const wallValue = num(values.wall_thickness_m, num(defaults.wall_thickness_m, null));
const slabValue = num(values.slab_thickness_m, num(defaults.slab_thickness_m, null));
const wall = wallValue && wallValue > 0 ? wallValue : FORD_WALL_THICKNESS_M;
const slab = slabValue && slabValue > 0 ? slabValue : FORD_SLAB_THICKNESS_M;
const wingIn = wingSpec(values, defaults, "in");
const wingOut = wingSpec(values, defaults, "out");
return {
@@ -363,7 +366,7 @@ function boxSet(
slab_thickness_m: slab,
top_thickness_m: slab,
cover_m: BOX_COVER_M,
span_m: boxSpanM(innerWidth || 2.0),
span_m: boxSpanM(innerWidth || 2.0, wall),
wing_in: wingIn,
wing_out: wingOut,
defaulted: [
@@ -76,6 +76,12 @@
"type_id": "ford_pavement",
"reuse_count": null,
"note": "물넘이포장은 거푸집이 서지 않는 구조(면 포장) — 대상 아님"
},
{
"type_id": "box_culvert",
"reuse_count": 6,
"matched_example": "관의 기초",
"note": "BOX암거 합판은 **버림 옆면뿐**(본체는 유로폼 — 12-38 유형 「박스」 보통) — 원문 6회 줄 「관의 기초 등 극히 간단한 구조」 · 실무 울진 2×2 「합판6회」 와 같음(2026-09-15)"
}
],
"reuse_ratio_pct": {
@@ -99,12 +105,11 @@
}
},
"shoring": {
"note": "강관동바리(품셈 12-20)는 **슬래브를 떠받칠 때** 필요하다. 지금 서는 구조물(옹벽·집수정)은 벽체 거푸집만이라 대상이 아니.",
"note": "강관동바리(품셈 12-20)는 **슬래브를 떠받칠 때** 필요하다. 옹벽·집수정은 벽체 거푸집만이라 대상이 아니고, BOX암거는 전개식이 동바리 줄을 낸다(2026-09-15).",
"targets_pending": [
"box_culvert",
"ford_bridge"
],
"why": "그 둘은 원단위·치수가 미확보라 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보"
"why": "세월교는 수량 근거가 없어 슬래브 면적 자체가 안 나온다 — 동바리도 함께 미확보"
},
"euroform_type": {
"note": "유로폼 설치·해체 유형(간단/보통/복잡) — **품셈 12-38-3 [주]④ 원문**이 시설 예시로 갈라 둔다. 거푸집 사용횟수(1-7-1)·철근 갈래(12-3 [주]①)와 같은 자리로, 사람이 고르는 값이 아니다.",
@@ -560,15 +560,10 @@
"not_found": {
"note": "규격은 우리 모델에 있으나 **관측 원단위가 어디에도 없는** 것. 지어내지 않는다.",
"items": [
{
"type_id": "box_culvert",
"why": "울진 2공구에 BOX암거가 실재하나 원단위 라이브러리에 탭이 없음. 게다가 structures.json 의 BOX 제원은 body_width_m·body_height_m 와 날개벽뿐이라 **벽·저판·상판 두께가 없어 전개식도 못 세움**.",
"needs": "표준 단면(벽·저판·상판 두께) 확보 — 사용자 확정 대기"
},
{
"type_id": "ford_bridge",
"why": "세월교 본체(날개벽 포함) 원단위 없음. 관 부분은 pipe 로 따로 섬.",
"needs": "표준도 물량 또는 실무 관측"
"why": "세월교 수량 근거 없음 — 법령·임도기술교본은 설치 조건만(그림 3-18 은 배치 모식도) · 실무 원본(울진 4공구·오솔길 전수)에 세월교 시트·도면 없음. 관 부분은 pipe 로 따로 섬(2026-09-15 브레인 판정 ⑥ — 사유로 닫음).",
"needs": "세월교 표준도 물량 또는 실무 관측 — 나오면 다시 엶"
},
{
"type_id": "retaining_wall",
@@ -435,6 +435,62 @@
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김",
"placing_note": "타설 코드는 프로젝트 설정의 타설 방식으로 갈림(기본 레디믹스트). 철근구조물 판정은 원단위의 D13·D16 에서 자동으로 나옴.",
"parts_note": "조각마다 **어느 성분에서 나오는지**(`from_components`)를 적는다. 이름을 바꾸면 물량이 조용히 0 이 되므로 원단위 성분 이름과 정확히 같아야 하고, 못 찾으면 드러낸다. `unit_from` 이 있으면 그 단위에서 목표 단위로 환산한다 — **철근은 ㎏ → ton (÷1000)**. ⚠ 단위를 안 맞추면 1000배 틀린다."
},
{
"type_id": "box_culvert",
"parts": [
{
"code": "FP-12-01-01",
"name": "콘크리트 타설",
"unit": "㎥",
"from_components": ["콘크리트", "버림콘크리트"],
"kind_suffix": "concrete_placing"
},
{
"code": "FP-12-38",
"name": "유로폼",
"unit": "㎡",
"from_components": ["유로폼"],
"kind_suffix": "euroform_type"
},
{
"code": "FP-12-04",
"name": "합판거푸집",
"unit": "㎡",
"from_components": ["합판거푸집"],
"kind_suffix": "formwork_reuse"
},
{
"code": "FP-12-03",
"name": "철근 현장가공 및 조립",
"unit": "ton",
"from_components": ["이형철근 D13", "이형철근 D16"],
"unit_from": "kg",
"kind_suffix": "rebar_complexity",
"why": "철근은 크기별 식이 없어 울진 기번3 구조도 2×2 관측값만 섬 — 다른 크기는 이 조각이 비어 묶음이 막힘(2026-09-15 브레인 판정 ②)"
},
{
"code": "FP-12-20",
"name": "강관동바리",
"unit": "㎥",
"from_components": ["동바리"]
},
{
"code": "FP-12-19",
"name": "강관비계",
"unit": "㎡",
"from_components": ["비계"]
},
{
"code": "FP-12-25",
"name": "기초잡석",
"unit": "㎥",
"from_components": ["기초잡석"],
"why": "BOX 근거 둘(예제·울진)에는 잡석 줄이 없으나 **버림이 선 구조물엔 기초잡석이 함께 섬**(사용자 확정 3차 ② · 옹벽과 같은 규칙) — 원단위에 선 줄을 묶음에서 빼면 수량만 보이고 금액이 빠짐"
}
],
"why": "품셈 12장에 「BOX암거」 공종이 없음 — 산림과임업기술(임도) 5장 수량산출 예제가 구체콘크리트·기초·거푸집·동바리·철근으로 가르고, 실무 울진 기번3 「암거수량집계표(2×2)」가 레미콘·버림·유로폼·합판·철근·동바리·비계로 셈(2026-09-15 브레인 12장 D).",
"needs": "일위대가 조립은 B09 몫 — B08 은 물량과 묶음만 넘김"
}
],
"unit_conversion": {
+6 -8
View File
@@ -19,31 +19,29 @@ if str(ROOT) not in sys.path:
from B06_Section.B06_Section_Engine_Culvert import ( # noqa: E402
BOX_COVER_M,
FORD_SLAB_THICKNESS_M,
FORD_WALL_THICKNESS_M,
_box_set,
attach_culvert_sets,
)
def test_box_set_defaults() -> None:
"""저장 옵션이 없으면 레지스트리 기본 2.0×2.0에 세월교 두께를 얹는다."""
"""옵션이 없으면 등록부 기본 2.0×2.0 · 두께도 등록부 제안값(2026-09-15 · 세월교 승계 걷음)."""
spec = _box_set(None)
assert spec["type"] == "box"
assert spec["inner_width_m"] == 2.0
assert spec["inner_height_m"] == 2.0
assert spec["wall_thickness_m"] == FORD_WALL_THICKNESS_M
assert spec["slab_thickness_m"] == FORD_SLAB_THICKNESS_M
assert spec["top_thickness_m"] == FORD_SLAB_THICKNESS_M
assert spec["wall_thickness_m"] == 0.3
assert spec["slab_thickness_m"] == 0.25
assert spec["top_thickness_m"] == 0.25
assert spec["cover_m"] == BOX_COVER_M
# 도로 방향 길이 = 내공 폭 + 측벽 두 장.
assert spec["span_m"] == 2.0 + 2 * FORD_WALL_THICKNESS_M
assert spec["span_m"] == 2.0 + 2 * 0.3
def test_box_wing_extends_slab() -> None:
"""저판 편측 연장 = 날개벽 길이 × cos(각도). 세월교와 같은 산식이다."""
spec = _box_set({"body_width_m": 3, "body_height_m": 3, "wing_in_length_m": 2})
assert spec["span_m"] == 3 + 2 * FORD_WALL_THICKNESS_M
assert spec["span_m"] == 3 + 2 * 0.3
assert spec["wing_in"]["slab_extend_m"] == round(2 * math.cos(math.radians(45)), 3)
@@ -0,0 +1,61 @@
"""BOX암거 두께·연장 칸 — 등록부가 정본, B06 그림이 그 칸을 읽음 (2026-09-15 브레인 12장 D ①③).
① 제안값 = 산림과임업기술(임도) 5장 수량산출 예제 — 벽 0.3 · 판 0.25 · 헌치 0.2 · 버림 0.1
근거에 KDS 44 90 00 4.5.2 「부재 최소두께 300㎜」(도로 기준)를 나란히 — 판 0.25 는 못 미침
② 종전 B06 상수(벽 0.2 · 판 0.3 — 세월교 값 승계)는 근거 없는 값이라 걷음
③ 연장 칸 — 기본값 없음
"""
from __future__ import annotations
import json
from pathlib import Path
from B06_Section.B06_Section_Engine_Culvert import _box_set
ROOT = Path(__file__).resolve().parents[2]
def _box_options() -> dict[str, dict]:
registry = json.loads(
(ROOT / "B05_Profile" / "B05_Profile_Structure_Types.json").read_text(encoding="utf-8")
)
types = registry if isinstance(registry, list) else registry.get("types") or []
box = next(t for t in types if t["type_id"] == "box_culvert")
return {option["key"]: option for option in box["options"]}
def test_등록부에_두께_헌치_버림_연장_칸이_선다() -> None:
options = _box_options()
expected = {
"wall_thickness_m": 0.3,
"slab_thickness_m": 0.25,
"haunch_m": 0.2,
"blinding_thickness_m": 0.1,
}
for key, value in expected.items():
assert options[key]["default"] == value, key
assert "산림과임업기술" in options[key]["default_basis"], key
# 판 0.25 는 KDS 도로 최소 300㎜ 에 못 미침 — 나란히 적어 설계자가 고르게.
assert "KDS" in options["slab_thickness_m"]["default_basis"]
assert "300" in options["slab_thickness_m"]["default_basis"]
assert "KDS" in options["wall_thickness_m"]["default_basis"]
# 연장은 기본값 없음.
assert options["length_m"]["default"] is None
def test_B06_세트가_등록부_칸을_읽는다() -> None:
spec = _box_set(None)
assert (spec["wall_thickness_m"], spec["slab_thickness_m"], spec["top_thickness_m"]) == (
0.3,
0.25,
0.25,
)
assert spec["span_m"] == 2.0 + 2 * 0.3
stored = _box_set({"wall_thickness_m": 0.35, "slab_thickness_m": 0.3})
assert (stored["wall_thickness_m"], stored["slab_thickness_m"], stored["top_thickness_m"]) == (
0.35,
0.3,
0.3,
)
assert stored["span_m"] == 2.0 + 2 * 0.35
@@ -0,0 +1,102 @@
"""BOX암거 전개식 — m당 × 연장 (2026-09-15 브레인 12장 D 판정 ①~⑤).
근거 둘(구조 같음)
산림과임업기술(임도) 5장 「구조물도에 의한 수량산출(예)」 — 구체 = (외곽 − 유수구 + 헌치) ·
기초 폭×두께 · 거푸집 외벽·유수구·받침판·귀면 · 동바리 유수구 단면
실무 울진 기번3 구조도 「암거수량집계표(2×2)」 m당 — 레미콘 2.84 · 버림 0.28 · 유로폼 11.132 ·
합판 0.2 · 철근 0.336t(H13 0.179 · H16 0.157) · 동바리 3.92 · 비계 벽체 5.2 + 시종점 13.52
⚠ 철근은 크기별 식이 없어 **2×2 관측값만** · 날개벽·토공은 사유.
"""
from __future__ import annotations
import pytest
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff
from B08_Quantity.B08_Quantity_Engine_Pipe import facility_structures
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table, expand
ULJIN_2X2 = {
"body_width_m": 2.0,
"body_height_m": 2.0,
"wall_thickness_m": 0.3,
"slab_thickness_m": 0.3,
"haunch_m": 0.2,
"blinding_thickness_m": 0.1,
"length_m": 10.0,
}
def _amounts(options: dict) -> tuple[dict, list[str], object]:
result = expand({"type_id": "box_culvert", "options": options}, {"box_culvert": "BOX암거"})
return (
{(c.name, c.spec): c.amount for c in result.components},
result.notes,
result,
)
def test_울진_2x2_m당_값을_연장에_곱한다() -> None:
got, notes, result = _amounts(ULJIN_2X2)
assert got[("콘크리트", "")] == pytest.approx(2.84 * 10)
assert got[("버림콘크리트", "")] == pytest.approx(0.28 * 10)
assert got[("유로폼", "")] == pytest.approx(
11.1314 * 10, abs=0.01
) # 원문 11.132(귀면 0.566 반올림)
assert got[("합판거푸집", "")] == pytest.approx(0.2 * 10)
assert got[("이형철근", "D13")] == pytest.approx(179 * 10)
assert got[("이형철근", "D16")] == pytest.approx(157 * 10)
assert got[("동바리", "")] == pytest.approx(3.92 * 10)
# 비계 — 벽체 m당 5.2 × 연장 + 시종점 두 면 13.52 는 한 번(원문은 m당에 넣었음 · 사유).
assert got[("비계", "")] == pytest.approx(5.2 * 10 + 13.52)
assert (result.billing_unit, result.billing_quantity) == ("m", 10.0)
text = " ".join(notes)
assert "관측" in text and "날개벽" in text
def test_예제_두께_기본값으로_구체가_줄어든다() -> None:
got, _notes, _r = _amounts({**ULJIN_2X2, "slab_thickness_m": 0.25})
# (2.6 × 2.5 2 × 2 + 0.2² ÷ 2 × 4) = 2.58㎥/m
assert got[("콘크리트", "")] == pytest.approx(2.58 * 10)
def test_2x2_가_아니면_철근은_사유() -> None:
got, notes, _r = _amounts({**ULJIN_2X2, "body_width_m": 3.0})
assert not [key for key in got if key[0] == "이형철근"]
assert any("크기별 식 없음" in note and "2×2" in note for note in notes)
assert got[("콘크리트", "")] > 0 # 나머지는 섬
def test_연장이_없으면_안_선다() -> None:
got, notes, _r = _amounts({k: v for k, v in ULJIN_2X2.items() if k != "length_m"})
assert got == {}
assert any("연장" in note for note in notes)
def test_치수를_안_적으면_기본값으로_서되_미확정() -> None:
rows = facility_structures(
[{"chainage_m": 50.0, "facility": "box_culvert", "options": {"length_m": 8.0}}]
)
box = next(row for row in rows if row["type_id"] == "box_culvert")
assert box["unconfirmed"] and "측벽 두께" in box["unconfirmed"]
assert box["options"]["wall_thickness_m"] == 0.3 and box["options"]["slab_thickness_m"] == 0.25
stored = facility_structures(
[{"chainage_m": 50.0, "facility": "box_culvert", "options": ULJIN_2X2}]
)
assert not next(r for r in stored if r["type_id"] == "box_culvert").get("unconfirmed")
def test_인계_묶음이_조각을_세운다() -> None:
table = build_table(
facility_structures(
[{"chainage_m": 50.0, "facility": "box_culvert", "options": ULJIN_2X2}]
),
{"box_culvert": "BOX암거"},
)
row = next(
r for r in build_handoff(unit_quantity_table=table)["work_items"] if "BOX" in r["name"]
)
parts = {part["code"].split("#")[0]: part for part in row["composite_parts"] or []}
assert {"FP-12-01-01", "FP-12-04", "FP-12-38", "FP-12-03", "FP-12-20", "FP-12-19"} <= set(parts)
assert parts["FP-12-03"]["quantity"] == pytest.approx((179 + 157) * 10 / 1000)
assert parts["FP-12-20"]["quantity"] == pytest.approx(3.92 * 10)
@@ -116,7 +116,8 @@ def test_독립_기슭막이_높이는_설계자_입력_없으면_미확정과_
def test_산출식_없는_세월교는_사유로_서고_터파기_빠짐_줄을_안_만든다() -> None:
table = _table([{"facility": "ford_bridge", "chainage_m": 30.0, "options": {}}])
assert "산출식이 아직 없습니다" in table["structures"][0]["notes"][0]
# 2026-09-15 브레인 판정 ⑥ — 「아직 없음」이 아니라 「근거 없음」으로 닫음.
assert "세월교 수량 근거 없음" in table["structures"][0]["notes"][0]
rows = build_handoff(unit_quantity_table=table)["work_items"]
assert not any("터파기가 안 선 구조물" in str(row.get("spec_detail")) for row in rows)
+15 -1
View File
@@ -114,8 +114,22 @@ def test_동바리는_대상이_없으면_없다고_말할것() -> None:
status = shoring_status()
assert status["applicable"] is False
assert status["reason"]
assert set(status["pending_types"]) == {"box_culvert", "ford_bridge"}
# BOX암거는 2026-09-15 전개식이 서서 대상에서 빠짐 — 세월교만 남음(근거 없음 사유).
assert set(status["pending_types"]) == {"ford_bridge"}
def test_표에도_동바리_상태가_실림() -> None:
assert build_table([옹벽()])["shoring"]["applicable"] is False
box = {
"type_id": "box_culvert",
"options": {
"body_width_m": 2.0,
"body_height_m": 2.0,
"wall_thickness_m": 0.3,
"slab_thickness_m": 0.3,
"haunch_m": 0.2,
"blinding_thickness_m": 0.1,
"length_m": 5.0,
},
}
assert build_table([box])["shoring"]["applicable"] is True
+8 -5
View File
@@ -169,13 +169,16 @@ def test_숫자와_글자_규격을_같이_본다() -> None:
assert table.find("retaining_wall", {"form": "반중력식", "height_m": 1.9999}) is None
def test_BOX암거는_미확보로_남음() -> None:
"""⚠ 두께가 저장돼 있지 않아 식도 못 세우고 관측값도 없다 — 지어내면 콘크리트·거푸집·
철근으로 번져 나간다. 사용자 확정 대기."""
def test_BOX암거는_관측표가_아니라_전개식으로_섬() -> None:
"""2026-09-15 — 두께 칸이 등록부에 서고 전개식(`_UnitQuantity_Box`)이 생겨 관측표 몫이 아님.
관측값은 여전히 없음(울진 2×2 철근만 전개식 안에 관측값으로 ) · 세월교는 사유로 닫힘.
"""
table = load_observed_table()
assert table.find("box_culvert", {"body_width_m": 3.0, "body_height_m": 1.2}) is None
reasons = [item["type_id"] for item in table.not_found["items"]]
assert "box_culvert" in reasons
reasons = {item["type_id"]: item for item in table.not_found["items"]}
assert "box_culvert" not in reasons
assert "세월교 수량 근거 없음" in reasons["ford_bridge"]["why"]
def test_파일이_없으면_전부_미확보() -> None: