feat(B08): 새 칸 다섯을 골막이·개거·물구멍에 물림 — 방수로가 정면적을 가름

골막이 (erosion_check)
- spillway 「있음/없음」 — 「없음」이면 파형강관 0.5024㎡ 를 안 뺌. 정면적이 밑수라
  일곱 줄이 +2.1~3.1 % 움직임. 「있음」이 정본과 같은 값(정본은 방수로 치수 0 인
  개소에서도 뺐음). 안 고르면 정본 쪽으로 서고 사유가 뜸.
- stone_kind · back_len_cm 를 읽음. 「칸이 없다」던 사유가 「안 골랐다」로 바뀜.

돌 무게 헬퍼 한 벌 (stone_weight_per_m2)
- 확정 5차 큰 것 7 규칙을 돌쌓기·골막이가 같이 씀. 계산식이 기본, 야면석만 관측표.
  두 벌로 두면 같은 확정이 구조물마다 갈림.

물구멍 헬퍼 한 벌 (weep_hole_spec)
- weep_hole_diameter_mm · weep_hole_area_m2 를 읽고 비우면 Ø50 · 2㎡당.
  지름은 규격(spec)으로 실어 단가가 갈리게 함.
- 정본이 개소당 관 길이를 평균두께(0.83m)로 잡는 것을 주석에 남김 — 우리 상수 0.5m
  와 갈리는 자리이고 미결임.

개거 — ditch_spec 칸이 와서 두 규격을 고를 수 있음.

시험 — 등록부 정책 예외에 새 칸 넷(ditch_spec · weep_hole 둘 · fill_slope_ratio)과
사토장 둘을 넣음. 613 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 12:16:39 +09:00
co-authored by Claude Opus 5
parent b891917e24
commit 92fd909212
2 changed files with 103 additions and 53 deletions
@@ -99,6 +99,55 @@ STONE_WEIGHT_FORMULA_BASIS = (
"⭐ 확정 5차 — 뒷길이 × 0.77(채움률) × 2.65(비중). ⚠ 잠정 — 실무자 협의 후 명확히 할 값" "⭐ 확정 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」. #: 뒷채움(막자갈) 폭 — ⭐ 사용자 확정 5차 작은 것 3 「막자갈 = (상 + 하) ÷ 2 × H」.
#: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면 #: ⚠⚠ **여기 상·하는 벽 두께가 아니라 뒷채움 폭이다.** 벽 두께(0.75/0.9)를 끌어다 쓰면
#: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다. #: H=2.0 에서 실무 0.75 가 1.66(입적)으로 튄다 — 옛 식이 곧 그 자리였다.
@@ -874,35 +923,18 @@ def stone_masonry(
) )
) )
# 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다. # 돌 무게 — ⭐ 확정 5차 큰 것 7. **계산식이 기본**이고 야면석만 관측표다(헬퍼 한 벌).
# ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고, # ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고,
# 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다). # 그래야 정본 H=2.0 의 「1.92 톤」과 맞는다(관측표 0.88 로 서면 1.84 로 4 % 낮다).
# 이름은 정본 줄 이름 그대로 **「돌」**이고, 종류를 고르면 그 이름으로 바뀐다. stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2(
if kind_label in STONE_WEIGHT_OBSERVED_KINDS: back_cm, kind_label, table["stone_ton_per_m2"]
# 야면석은 공극이 커 계산식이 안 맞는다 — 원문 관측표를 그대로 쓴다. )
stone_name = kind_label weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else ""
stone_ton = table["stone_ton_per_m2"] if not kind_label:
weight_basis = ( notes.append(
f"쌓기 × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝) · {STONE_WEIGHT_BASIS}" " 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
if stone_ton is not None "야면석이면 계산식이 안 맞아 관측표로 갈립니다"
else ""
) )
weight_source = STONE_WEIGHT_SOURCE
else:
stone_name = kind_label or ""
stone_ton = back_m * STONE_PACKING_RATIO * STONE_DENSITY_TON_M3
weight_basis = (
f"돌쌓기 × {stone_ton:.3f} ton/㎡ = 뒷길이 {back_m:g} × {STONE_PACKING_RATIO}"
f" × {STONE_DENSITY_TON_M3}"
+ (f" · {kind_label}" if kind_label else " · ⚠ 돌 종류를 안 골랐음")
+ f" · {STONE_WEIGHT_FORMULA_BASIS}"
)
weight_source = ""
if not kind_label:
notes.append(
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
"야면석이면 계산식이 안 맞아 관측표로 갈립니다"
)
if stone_ton is None: if stone_ton is None:
# 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮). # 원본 표가 비어 있는 칸이다 — 지어내지 않고 알린다(PLAN 8-8 ㉮).
notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함") notes.append(f"뒷길이 {back_cm}㎝ 의 돌중량이 원본 표에 없어 {stone_name}을 내지 못함")
@@ -973,16 +1005,20 @@ def stone_masonry(
# 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인). # 지금 쓰는 13-4 계열에는 제잡비 행 자체가 없어 겹치지 않는다(전수 확인).
# ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면 # ⚠ 관종·지름은 미확정 — 법은 「지름 3~6㎝ 파이프」, 실무 관측은 Ø50. 규격이 정해지면
# 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다. # 이름에 붙인다(`물구멍 Ø50`). 지어내지 않고 규격 없는 이름으로 둔다.
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 —
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식).
# ⓘ 정본은 개소당 관 길이를 **평균두께**로 잡는다(H=2.0 에서 0.83m). 우리는 상수 0.5m 라
# 그만큼 짧게 선다 — 관은 벽을 가로지르므로 정본 쪽이 이치에 맞는다(미결).
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
components.append( components.append(
Component( Component(
"물구멍관", "물구멍관",
"m", "m",
masonry_area / constants["weep_hole_area_m2"] * constants["weep_hole_length_m"], masonry_area / hole_area * constants["weep_hole_length_m"],
DESTINATION["물구멍관"], DESTINATION["물구멍관"],
# ⚠ 「미확정」만 적으면 사용자가 무엇을 정해야 하는지 모른다 — f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {constants['weep_hole_length_m']:g} m/개소"
# **지금 무슨 값으로 돌고 있는지**를 함께 적는다(원단위 미확보와 같은 방식). f" · {hole_basis}",
"돌쌓기 ÷ 2㎡/개소 × 0.5 m/개소 · ⚠ 잠정: 관 Ø 미정(법 3~6㎝ / 실무 Ø50) ·" spec=f"Ø{hole_dia}",
" 간격 2.0㎡당 1개소(법 2~3㎡당 1개소 이상)",
) )
) )
@@ -210,6 +210,8 @@ def erosion_check_dam(
STONE_MASONRY, STONE_MASONRY,
_back_length, _back_length,
fill_concrete_mpa, fill_concrete_mpa,
stone_weight_per_m2,
weep_hole_spec,
) )
form = str(options.get("form") or "").strip() form = str(options.get("form") or "").strip()
@@ -239,8 +241,8 @@ def erosion_check_dam(
return [], [f"뒷길이 {back_cm}㎝ 는 품셈 표(25·30·35·45·55·60·75㎝)에 없어 계수가 없습니다"] 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")): if not any(options.get(key) is not None for key in ("back_len_cm", "stone_back_length_cm")):
notes.append( notes.append(
f"골막이 제원에 **뒷길이 칸이 없어** 기본 {back_cm}㎝ 로 섰습니다 " f"뒷길이를 안 골라 기본 {back_cm}㎝ 로 섰습니다 "
"— 그 값이 두께식·고임돌·야면석·채움콘크리트 계수를 모두 가릅니다" "— 그 값이 두께식·고임돌·돌 무게·채움콘크리트 계수를 모두 가릅니다"
) )
back_m = back_cm / 100.0 back_m = back_cm / 100.0
@@ -252,13 +254,21 @@ def erosion_check_dam(
slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)" slope_note = f"1:{slope:g} — 정본 「반수면비탈」 붙박이(안 정함)"
# ① 정면적(사다리꼴) − ② 방수로 파형강관 단면 # ① 정면적(사다리꼴) − ② 방수로 파형강관 단면
# ⚠ 「없음」이면 안 뺀다 — 정면적이 밑수라 **열한 줄이 통째로 움직인다.**
# ⓘ 정본은 방수로 치수가 0 인 개소에서도 뺐다. 그러므로 **정본과 같은 값은 「있음」**이고,
# 「없음」은 정본보다 크게 나온다. 안 고르면 정본 쪽(「있음」)으로 서고 그 사실을 알린다.
trapezoid = _floor2((top_m + bottom_m) / 2 * height_m) trapezoid = _floor2((top_m + bottom_m) / 2 * height_m)
spillway = str(options.get("spillway") or "").strip()
pipe = const["spillway_pipe_r_m"] ** 2 * const["spillway_pipe_pi"] pipe = const["spillway_pipe_r_m"] ** 2 * const["spillway_pipe_pi"]
front_area = trapezoid - pipe if spillway == "없음":
notes.append( front_area = trapezoid
f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8) " notes.append("방수로 「없음」이라 파형강관 단면을 안 뺐습니다 — 정본보다 정면적이 큽니다")
"⚠ 「방수로 없음」을 고를 칸이 등록부에 없어 늘 뺍니다" else:
) front_area = trapezoid - pipe
notes.append(
f"방수로 파형강관 {pipe:.4f}㎡ 를 정면적에서 뺐습니다 — 정본 붙박이(Ø0.8)"
+ ("" if spillway else " · ⚠ 방수로를 안 골라 정본대로 뺐습니다")
)
masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0))) masonry = _floor2(front_area * _round2(math.hypot(slope, 1.0)))
top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m) top_t = _floor2(back_m + const["thickness_top_per_m"] * height_m)
@@ -285,16 +295,20 @@ def erosion_check_dam(
), ),
] ]
stone_ton = coeff["stone_ton_per_m2"] # 돌 무게 — ⭐ 확정 5차 큰 것 7. 돌쌓기와 **같은 헬퍼**를 쓴다(규칙이 하나여야 한다).
kind = str(options.get("stone_kind") or "").strip()
stone_name, stone_ton, weight_tail, _src = stone_weight_per_m2(
back_cm, kind, coeff["stone_ton_per_m2"]
)
if stone_ton is None: if stone_ton is None:
notes.append(f"뒷길이 {back_cm}㎝ 에 야면석 중량 칸이 비어 있어 돌을 못 세웠습니다") notes.append(f"뒷길이 {back_cm}㎝ 에 {stone_name} 중량 칸이 비어 있어 돌을 못 세웠습니다")
else: else:
rows.append( rows.append(
( (
"야면석", stone_name,
"ton", "ton",
_ceil2(base_area * stone_ton), _ceil2(base_area * stone_ton),
f"(돌쌓기+돌붙임) {base_area:.4f} × {stone_ton} ton/㎡ (뒷길이 {back_cm}㎝)", f"(돌쌓기+돌붙임) {base_area:.4f}{weight_tail}",
) )
) )
wedge_per = coeff["wedge_stone_m3_per_m2"] wedge_per = coeff["wedge_stone_m3_per_m2"]
@@ -312,15 +326,14 @@ def erosion_check_dam(
"입적 − ((돌쌓기+돌붙임) × 뒷길이 × 2/3 + 고임돌)", "입적 − ((돌쌓기+돌붙임) × 뒷길이 × 2/3 + 고임돌)",
) )
) )
hole_area, hole_dia, hole_basis = weep_hole_spec(options)
rows.append( rows.append(
( (
"물구멍관", "물구멍관",
"m", "m",
_round2( _round2(masonry / hole_area * STONE_MASONRY["weep_hole_length_m"]),
masonry / STONE_MASONRY["weep_hole_area_m2"] * STONE_MASONRY["weep_hole_length_m"] f"돌쌓기 ÷ {hole_area:g}㎡/개소 × {STONE_MASONRY['weep_hole_length_m']:g}m/개소"
), f" · {hole_basis}{hole_dia})",
f"돌쌓기 ÷ {STONE_MASONRY['weep_hole_area_m2']:g}㎡/개소 × "
f"{STONE_MASONRY['weep_hole_length_m']:g}m/개소",
) )
) )
@@ -360,9 +373,10 @@ def erosion_check_dam(
spec=f"{mpa}", spec=f"{mpa}",
) )
) )
if not options.get("stone_kind"): if not kind:
notes.append( notes.append(
"돌 종류 칸이 골막이 등록부에 없어 **야면석**으로 섰습니다 — 정본 탭이 그 돌입니다" "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 "
"— 정본 탭은 야면석이고, 야면석은 계산식이 안 맞아 관측표로 갈립니다"
) )
return components, notes return components, notes
@@ -389,8 +403,8 @@ def erosion_check_dam(
#: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** — #: 본체 표를 못 찾았으므로 **「원문에 없음」으로 두고 그 사실을 화면에 적는다** —
#: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게. #: 「콘크리트 개거인데 콘크리트가 0」이 우리 결함으로 읽히지 않게.
#: #:
#: **규격이 붙박이다** — 정본에 「150×200」 하나뿐이고 `open_ditch` 등록부에 **옵션이 하나도 #: **규격 칸이 생겼다**(2026-09-09 랩탑 메인 `0b0763ed`) — 두 규격을 고를 수 있고,
#: 없다**(규격 칸 없음). 칸이 생기면 이 표를 열면 된다. #: 비우면 「콘크리트 개거 150×200」으로 선다.
OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = { OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
"콘크리트 개거 150×200": { "콘크리트 개거 150×200": {
"rows": ( "rows": (
@@ -500,8 +514,8 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
for name, unit, per_m, basis in table["rows"] for name, unit, per_m, basis in table["rows"]
] ]
notes: list[str] = [] notes: list[str] = []
if "ditch_spec" not in options: if not options.get("ditch_spec"):
notes.append(f"규격{spec} 붙박이입니다 — 개거 제원에 규격 칸이 없습니다") notes.append(f"규격을 안 골라{spec}으로 섰습니다 — 제원에서 고를 수 있습니다")
if spec == "콘크리트 개거 150×200": if spec == "콘크리트 개거 150×200":
notes.append( notes.append(
"⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다" "⚠ 이 표에는 **콘크리트 본체 줄이 없습니다** — 정본 원문 그대로입니다"