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

This commit is contained in:
2026-09-13 17:25:27 +09:00
10 changed files with 227 additions and 31 deletions
@@ -178,6 +178,8 @@ def _rows_of(structure: dict[str, Any]) -> list[dict[str, Any]]:
"unit_amount": _unit_amount(amount, quantity), "unit_amount": _unit_amount(amount, quantity),
"amount": amount, "amount": amount,
"unit": component.get("unit") or "", "unit": component.get("unit") or "",
# 갈 곳 — 이중계상 경계를 줄마다 보임(명세 13장 Ⓑ). 전개가 이미 싣고 옴.
"destination": component.get("destination") or "",
# 값이 식에서 나왔나(derived) 관측 원단위표에서 왔나(observed) — 되짚기용. # 값이 식에서 나왔나(derived) 관측 원단위표에서 왔나(observed) — 되짚기용.
"basis_kind": component.get("basis_kind") or "", "basis_kind": component.get("basis_kind") or "",
"source": component.get("source") or "", "source": component.get("source") or "",
@@ -72,3 +72,83 @@ def template_sheet(template: dict[str, Any], values: dict[str, Any]) -> dict[str
"vars": values, "vars": values,
"tables": template.get("tables") or {}, "tables": template.get("tables") or {},
} }
#: 양식이 m당으로 풀리는 단위 — 연장 L=1 로 풀면 곧 단위당 값(모든 줄이 L 에 비례).
_PER_LENGTH_UNITS = frozenset({"m"})
def _library_rows(
template: dict[str, Any], solved: list[dict[str, Any]], billing: float
) -> list[dict[str, Any]]:
"""풀이 결과를 구조물도 줄 모양으로 — 식·설명·반올림·갈 곳·안 섬까지 실음(명세 13장)."""
by_seq = {row["seq"]: row for row in template.get("rows") or []}
rows: list[dict[str, Any]] = []
for result in solved:
source = by_seq.get(result["seq"]) or {}
unit_amount = float(result["amount"]) if result.get("amount") is not None else None
rows.append(
{
"no": result["seq"],
"name": result["name"],
"spec": result.get("spec") or "",
"basis": source.get("formula_text") or "",
"formula": source.get("formula") or "",
"unit_amount": unit_amount,
"amount": None if unit_amount is None else unit_amount * billing,
"unit": source.get("unit") or "",
"basis_kind": "derived",
"source": source.get("source") or "library",
"destination": source.get("destination") or "",
"rounding": source.get("rounding"),
"skipped": bool(result.get("skipped")),
"reason": result.get("reason") or "",
"error": result.get("error") or "",
}
)
return rows
def apply_templates(payload: dict[str, Any], settings: dict[str, Any] | None = None) -> None:
"""구조물도 장마다 **양식이 있으면 양식으로 줄을 다시 세움**(자리에서 고침).
⚠ 양식이 없는 종류는 지금 전개 줄 그대로 — `formula` 빈칸 = 고정형 모양(명세 13장).
⚠ Node 풀이가 안 돌면 전개 줄을 두고 **그 사실을 장 사유에 적음** — 조용히 넘기지 않음.
⚠ 장은 제원 조합 하나라 **L=1(m당)** 으로 풂 — 연장은 제원이 아니고 모든 줄이 L 에 비례.
"""
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets
from B08_Quantity.B08_Quantity_Engine_StructureSheet import slope_of
targets: list[tuple[dict[str, Any], dict[str, Any]]] = []
for sheet in payload.get("sheets") or []:
template = load_template(str(sheet.get("type_id") or ""))
if template is None or sheet.get("billing_unit") not in _PER_LENGTH_UNITS:
for row in sheet.get("rows") or []:
row.setdefault("formula", "")
row["source"] = row.get("source") or "auto"
continue
structure = {
"height_m": sheet.get("height_m"),
"length_m": 1.0,
"options": sheet.get("options"),
}
values = template_vars(template, structure, slope_of(sheet)[0], settings)
targets.append((sheet, template_sheet(template, values)))
if not targets:
return
solved = evaluate_sheets([body for _sheet, body in targets])
for index, (sheet, _body) in enumerate(targets):
template = load_template(str(sheet.get("type_id") or "")) or {}
if solved is None:
sheet["notes"].append("⚠ 식 풀이기를 못 돌려 양식 대신 지금 전개 값으로 섰음")
continue
members = sheet.get("members") or []
billing = float(members[0].get("billing_quantity") or 0.0) if members else 0.0
sheet["rows"] = _library_rows(template, solved[index], billing)
sheet["unpriced_rows"] = [
row["name"]
for row in sheet["rows"]
if row["unit_amount"] is None and not row["skipped"]
]
sheet["library_item"] = {"type_id": template.get("type_id"), "name": template.get("name")}
@@ -600,14 +600,6 @@ def _blinding_component(
) )
#: 기초잡석(품셈 12-25)이 아직 못 서는 까닭 — **폭은 있고 두께가 없다.**
RUBBLE_BASE_BLOCKED = (
"기초잡석(12-25) 물량이 안 섬 — 폭은 하단 길이 {width:.2f}m 로 섰으나 **두께가 원문에 "
"없음**(품셈 12-25 는 ㎥당 품만 줌). 실무 관측은 관보호공 날개벽 T=0.2 하나뿐이라 "
"다른 구조물 값을 옮겨 쓰지 않음"
)
#: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인). #: ⚠ **저장 제원의 실제 칸 이름**은 `back_len_cm` 이다(레지스트리 확인).
#: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다** #: 앞서 `stone_back_length_cm` 을 읽고 있어 **저장값이 영영 안 닿고 늘 기본 45㎝ 로 돌았다**
#: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다. #: — 뒷길이를 75 로 골라도 45 계수가 붙던 자리다. 값이 나오므로 아무 시험도 안 잡았다.
@@ -906,6 +898,13 @@ def stone_masonry(
# ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로 # ⭐ 확정 5차 작은 것 3 이 막자갈을 **뒷채움 폭 사다리꼴**로 못 박았다. 표는 그대로
# 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다. # 두되(다른 자리에서 쓸 수 있다) 여기서는 안 쓴다.
kind_label = str(picked.get("kind") or "") 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 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도 # ⚠ `face_slope_ratio` 는 **2026-09-09 에 칸이 생겼다**(돌쌓기 계열 여섯 종류 · 표준도
# 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이 # 제원 폼). 빈 값이 「자동」의 뜻이라 비어 있으면 아래 표준경사표가 돌고, 채우면 그 값이
# 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.) # 이긴다. (키 이름 어긋남으로 저장값이 안 닿던 `back_len_cm` 사고와 구별할 것.)
@@ -986,10 +985,10 @@ def stone_masonry(
# ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고, # ⚠ 안 고른 경우도 **계산식**으로 선다 — 정본 여섯 탭이 종류를 안 적고 그 식을 쓰고,
# 그래야 정본 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( stone_name, stone_ton, weight_tail, weight_source = stone_weight_per_m2(
back_cm, kind_label, table["stone_ton_per_m2"] back_cm, stone_kind_name, table["stone_ton_per_m2"]
) )
weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else "" weight_basis = f"돌쌓기 {weight_tail}" if weight_tail else ""
if not kind_label: if not stone_kind_name:
notes.append( notes.append(
"돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — " "돌 종류를 안 골라 **계산식**(뒷길이 × 0.77 × 2.65)으로 섰습니다 — "
"야면석이면 계산식이 안 맞아 관측표로 갈립니다" "야면석이면 계산식이 안 맞아 관측표로 갈립니다"
@@ -1124,8 +1123,8 @@ def stone_masonry(
) )
if blinding is not None: if blinding is not None:
components.append(blinding) components.append(blinding)
# ⓘ 기초잡석은 `build_table` 이 버림 뒤에 세움(확정 3차 ② 두께 0.2 · PLAN 10장 「계상함」).
notes.append(RUBBLE_BASE_BLOCKED.format(width=base_width)) # 옛 「두께가 없어 안 섬」 사유는 걷음 — 줄과 사유가 함께 떠 서로 어긋났음(2026-09-13 화면 실측).
# 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다). # 터파기·되메우기·잔토 — 토공으로 합산되는 값이다(내역 줄이 아니다).
components.extend( components.extend(
@@ -52,19 +52,23 @@ def project_structure_sheets(
모듈 위에서 부르면 맞물린다. 모듈 위에서 부르면 맞물린다.
""" """
from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets from B08_Quantity.B08_Quantity_Engine_StructureSheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import apply_templates
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
from common_util.common_util_project_settings import quantity_settings from common_util.common_util_project_settings import quantity_settings
structures, names, skipped = _collect_structures(project_root) structures, names, skipped = _collect_structures(project_root)
settings = quantity_settings(project_root)
unit_table = build_unit_table( unit_table = build_unit_table(
structures, structures,
names, names,
section_modes, section_modes,
ground_types, ground_types,
quantity_settings(project_root).get("rubble_base_thickness_m"), settings.get("rubble_base_thickness_m"),
) )
payload = build_standard_sheets(unit_table, section_modes) payload = build_standard_sheets(unit_table, section_modes)
# 양식이 있는 종류는 줄마다 식·설명·반올림·갈 곳을 실음(PLAN 3장 ④ · 명세 13장).
apply_templates(payload, settings)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다. # 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped payload["skipped_structures"] = skipped
return payload return payload
+52 -6
View File
@@ -26,10 +26,53 @@ export interface StructureSheetRow {
basis: string; basis: string;
/** 단위당 값 — 단위 수량을 못 정한 장은 `null`(0 으로 때우지 않음). */ /** 단위당 값 — 단위 수량을 못 정한 장은 `null`(0 으로 때우지 않음). */
unit_amount: number | null; unit_amount: number | null;
amount: number; amount: number | null;
unit: string; unit: string;
basis_kind: string; basis_kind: string;
source: string; source: string;
/** 양식 줄만 — 기계가 푸는 식(명세 13장). 비면 고정형(지금 전개). */
formula?: string;
destination?: string;
rounding?: { mode: string; digits: number } | null;
/** `when` 이 거짓이라 안 선 줄 — 「안 섬」과 까닭을 보임(0 으로 안 적음). */
skipped?: boolean;
reason?: string;
error?: string;
}
const DESTINATION_LABELS: Record<string, string> = {
earthwork: "토공집계",
material: "자재총괄",
unit_price: "일위대가",
reference: "보여주기",
haul_deduction: "운반 공제",
};
const ROUNDING_LABELS: Record<string, string> = {
floor: "내림(INT)",
trunc: "버림",
round: "반올림",
ceil_away: "올림",
ceil: "위로",
round_half_even: "짝수 반올림",
};
/** 수량 칸 — 안 선 줄은 「안 섬」, 못 푼 줄은 「-」(0 으로 때우지 않음). */
function amountText(row: StructureSheetRow): string {
if (row.skipped) return "안 섬";
return num(row.unit_amount, 3);
}
/** 비고 칸 — 까닭이 있으면 까닭이 먼저, 없으면 값의 출처와 반올림. */
function noteText(row: StructureSheetRow): string {
if (row.skipped) return row.reason ?? "";
if (row.error) return `${row.error}`;
const origin =
row.source === "library" ? "양식" : row.basis_kind === "observed" ? "실무 관측" : "치수 전개";
const mode = row.rounding?.mode;
return mode && mode !== "none"
? `${origin} · ${ROUNDING_LABELS[mode] ?? mode} ${row.rounding?.digits}자리`
: origin;
} }
export interface StructureSheet extends StandardSheetSpec { export interface StructureSheet extends StandardSheetSpec {
@@ -67,7 +110,8 @@ const CSS = `
.b08-sheet__tabs { flex-wrap: wrap; } .b08-sheet__tabs { flex-wrap: wrap; }
.b08-sheet .b08-grid__table--summary td:nth-child(5) { text-align: center; } .b08-sheet .b08-grid__table--summary td:nth-child(5) { text-align: center; }
/* 산출 근거는 길다 — 접지 않으면 수량 칸이 화면 밖으로 밀림(2026-09-13 화면 실측). */ /* 산출 근거는 길다 — 접지 않으면 수량 칸이 화면 밖으로 밀림(2026-09-13 화면 실측). */
.b08-sheet__rows td:nth-child(3) { white-space: normal; min-width: 16rem; } .b08-sheet__rows td:nth-child(3) { white-space: pre-line; min-width: 16rem; }
.b08-sheet__rows td:nth-child(7) { white-space: normal; max-width: 18rem; }
@media (max-width: 900px) { @media (max-width: 900px) {
.b08-sheet { flex-direction: column; } .b08-sheet { flex-direction: column; }
.b08-sheet__aside { flex-basis: auto; width: 100%; } .b08-sheet__aside { flex-basis: auto; width: 100%; }
@@ -177,15 +221,17 @@ function sheetBody(sheet: StructureSheet): HTMLElement {
} else { } else {
main.append( main.append(
table( table(
["공종", "규격", "산출 근거", "수량", "단위", "비고"], ["공종", "규격", "산출 근거", "수량", "단위", "갈 곳", "비고"],
sheet.rows.map((row) => [ sheet.rows.map((row) => [
row.name, row.name,
row.spec, row.spec,
// 근거 문구의 `**강조**` 는 서버 문서용 표기 — 표에서는 떼고 보임. // 근거 문구의 `**강조**` 는 서버 문서용 표기 — 표에서는 떼고 보임.
row.basis.replace(/\*\*/g, ""), // 양식 줄은 **식을 버리지 않고** 설명 밑에 함께 보임(명세 13장 지킬 것 ④).
num(row.unit_amount, 3), row.basis.replace(/\*\*/g, "") + (row.formula ? `\n= ${row.formula}` : ""),
amountText(row),
row.unit, row.unit,
row.basis_kind === "observed" ? "실무 관측" : "치수 전개", DESTINATION_LABELS[row.destination ?? ""] ?? row.destination ?? "",
noteText(row),
]), ]),
"b08-sheet__rows", "b08-sheet__rows",
), ),
+1 -2
View File
@@ -7,8 +7,7 @@
"note": "구조물도 양식형 항목 첫 벌(PLAN 3장 ② · 명세 13장 식 칸 계약). 값은 지금 전개(`B08_Quantity_Engine_UnitQuantity.stone_masonry` + 기초잡석)와 같게 둠(판정 Ⓑ). 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않고 제원 vars 와 표 한 벌로 수량이 다시 남(명세 16장).", "note": "구조물도 양식형 항목 첫 벌(PLAN 3장 ② · 명세 13장 식 칸 계약). 값은 지금 전개(`B08_Quantity_Engine_UnitQuantity.stone_masonry` + 기초잡석)와 같게 둠(판정 Ⓑ). 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않고 제원 vars 와 표 한 벌로 수량이 다시 남(명세 16장).",
"differs_from_engine": [ "differs_from_engine": [
"원문 「-」 칸(야면석 75·깬돌 25·견치돌 25/30 고임돌)에서 전개는 고임돌 줄을 안 세우고 채집석에 0 을 더함. 이 양식은 고임돌 줄이 「표 칸이 비어 있음」 오류로 서고 채집석도 막힘 — 명세 13장 Ⓐ(안 선 줄을 0 으로 치지 않음)을 따른 것", "원문 「-」 칸(야면석 75·깬돌 25·견치돌 25/30 고임돌)에서 전개는 고임돌 줄을 안 세우고 채집석에 0 을 더함. 이 양식은 고임돌 줄이 「표 칸이 비어 있음」 오류로 서고 채집석도 막힘 — 명세 13장 Ⓐ(안 선 줄을 0 으로 치지 않음)을 따른 것",
"돌 줄 이름 — 전개는 돌종류 이름(야면석·호박돌…/돌), 양식은 이름 「돌」 고정 + spec 에 종류(명세 13장 Ⓒ)", "돌 줄 이름 — 전개는 돌종류 이름(야면석·호박돌…/돌), 양식은 이름 「돌」 고정 + spec 에 종류(명세 13장 Ⓒ)"
"「실무 관행」 계수를 고르면 전개는 돌종류까지 지워 돌 줄을 「돌」·계산식 무게로 세움(야면석을 골라도). 관행은 계수 열만 바꾸는 칸이라 전개 쪽 결함으로 보고 양식은 종류를 지킴 — 판정 요청"
], ],
"vars": { "vars": {
"H": { "label": "높이(m)", "source": "height_m" }, "H": { "label": "높이(m)", "source": "height_m" },
+23 -3
View File
@@ -65,12 +65,32 @@ def test_폭은_하단_길이에서_온다() -> None:
assert "잠정" not in got.basis assert "잠정" not in got.basis
def test_기초잡석은_두께가_없어_안_() -> None: def test_기초잡석은_계상하고_안_선다는_사유를_안_남긴() -> None:
"""⚠ 폭은 생겼지만 **두께가 원문에 없다** — 지어내지 않고 사유로 드러낸다.""" """ⓘ 2026-09-13 바뀜 — 옛 시험은 「두께가 원문에 없어 안 선다」를 굳혔음.
확정 3 두께 0.2 서고 `build_table` 기초잡석 줄을 세우는데, 전개는 여전히
사유를 붙여 **구조물도에 줄과 사유가 함께 서로 어긋났음**(화면 실측).
브레인 판정(PLAN 10 기초잡석 계상함)대로 사유를 걷고 줄이 서는지 .
"""
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table
_, notes = stone_masonry( _, notes = stone_masonry(
2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, wet=True 2.5, 10.0, {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45}, wet=True
) )
assert any("기초잡석(12-25)" in n and "두께가 원문에 없음" in n for n in notes) assert not any("기초잡석(12-25)" in n and "안 섬" in n for n in notes)
table = build_table(
[
{
"structure_id": "s1",
"type_id": "masonry_wet",
"start_m": 0.0,
"end_m": 10.0,
"options": {"height_m": 2.5, "length_m": 10.0, "back_len_cm": 45},
}
]
)
names = [c["name"] for c in table["structures"][0]["components"]]
assert "기초잡석" in names
def test_큰돌쌓기에도_선다() -> None: def test_큰돌쌓기에도_선다() -> None:
@@ -82,6 +82,36 @@ def test_조회가_장을_낸다(client: TestClient) -> None:
assert all("spec" in row for row in sheet["rows"]) assert all("spec" in row for row in sheet["rows"])
def test_양식이_있는_종류는_줄마다_식을_싣는다(client: TestClient) -> None:
"""PLAN 3장 ④ · 명세 13장 — 찰쌓기는 양식으로 줄이 서고 식·설명·갈 곳·출처가 실림."""
sheet = _sheet(client)
assert sheet["library_item"]["type_id"] == "masonry_wet"
rows = {row["name"]: row for row in sheet["rows"]}
돌쌓기 = rows["돌쌓기"]
assert 돌쌓기["formula"] == "H*L*SQRT(1+N^2)"
assert "비탈면적" in 돌쌓기["basis"]
assert 돌쌓기["source"] == "library" and 돌쌓기["destination"] == "unit_price"
assert all(row["destination"] for row in sheet["rows"]) # 갈 곳 빈 줄 없음
# 돌 줄은 이름 고정 — 종류를 안 골라 규격이 빔(매칭 성공 아님, 명세 13장 Ⓒ).
assert rows[""]["spec"] == ""
# m당 값 — 제원(H=2.5·뒷길이 45·1:0.3)으로 다시 셈한 값.
assert 돌쌓기["unit_amount"] == pytest.approx(2.5 * (1 + 0.3**2) ** 0.5)
def test_안_선_줄은_안_섬으로_실린다(client: TestClient) -> None:
"""버림을 「안 넣음」으로 저장하면 버림·기초잡석이 0 이 아니라 「안 섬」으로 옴."""
sheet = _sheet(client)
saved = client.put(
f"{SHEETS}/spec",
json={"sheet_key": sheet["key"], "base_revision": 1, "blinding_concrete": "안 넣음"},
)
assert saved.status_code == 200, saved.text
rows = {row["name"]: row for row in _sheet(client)["rows"]}
for name in ("버림콘크리트", "기초잡석"):
assert rows[name]["skipped"] is True and rows[name]["unit_amount"] is None, rows[name]
assert rows[name]["reason"], rows[name]
def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None: def test_기초잡석_두께가_산출_조건을_따른다(client: TestClient, project: Path) -> None:
기본 = _unit_amount(_sheet(client), "기초잡석") 기본 = _unit_amount(_sheet(client), "기초잡석")
(project / "project_settings.json").write_text( (project / "project_settings.json").write_text(
@@ -99,14 +99,8 @@ def test_양식_값이_전개와_같다(case: dict) -> None:
[r["name"] for r in rows], [r["name"] for r in rows],
[c["name"] for c in components], [c["name"] for c in components],
) )
# ⚠ 알려진 차이 하나 — 「실무 관행」 계수를 고르면 전개가 돌종류까지 지워 돌 줄이 이름 「돌」· # ⓘ 「실무 관행」 계수가 돌종류를 지우던 전개 결함은 2026-09-13 고침(브레인 판정) — 차이 없음.
# 계산식 무게로 섬(야면석을 골라도). 관행은 **계수 열만** 바꾸는 칸이라 전개 쪽 결함으로 보고
# 양식은 종류를 지킴 — 양식 `differs_from_engine` · PLAN 3장에 판정 요청으로 적음.
practice_drops_kind = case.get("stone_coeff_basis") == "실무 관행" and case.get("stone_kind")
for row, component in zip(rows, components): for row, component in zip(rows, components):
if row["name"] == "" and practice_drops_kind:
assert (component["name"], row["spec"]) == ("", case["stone_kind"])
continue
if row["name"] == "": if row["name"] == "":
# 명세 13장 Ⓒ — 양식은 이름 고정 + 규격에 종류, 전개는 종류를 이름으로 씀. # 명세 13장 Ⓒ — 양식은 이름 고정 + 규격에 종류, 전개는 종류를 이름으로 씀.
assert component["name"] == (row["spec"] or "") assert component["name"] == (row["spec"] or "")
@@ -271,6 +271,28 @@ def test_물구멍_잠정값이_근거에_적힐것() -> None:
assert "2~3㎡" in basis # 법이 정한 범위 assert "2~3㎡" in basis # 법이 정한 범위
def test_실무_관행_계수가_돌종류를_지우지_않는다() -> None:
"""2026-09-13 브레인 판정 — 계수 열 고르기와 돌종류는 다른 축.
전개는 실무 관행 고르면 야면석이 ·계산식 무게(0.918) 조용히 섰음.
이제 계수만 참고자료 (고임돌 0.15) 가고, 돌은 야면석 이름·관측 무게(0.88) 지킴.
"""
result = expand(
돌쌓기찰(
height=2.0,
length=10.0,
back_len_cm=45,
stone_kind="야면석·호박돌",
stone_coeff_basis="실무 관행",
face_slope_ratio=0.3,
)
)
돌쌓기 = 성분(result, "돌쌓기").amount
assert 성분(result, "야면석·호박돌").amount == pytest.approx(돌쌓기 * 0.88)
assert 성분(result, "고임돌").amount == pytest.approx(돌쌓기 * 0.15) # 관행 열
assert not any(c.name == "" for c in result.components)
def test_물구멍관_길이는_평균두께() -> None: def test_물구멍관_길이는_평균두께() -> None:
"""2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = 평균두께(옛 상수 0.5m 아님). """2026-09-13 브레인 판정(PLAN 10장) — 개소당 관 길이 = 평균두께(옛 상수 0.5m 아님).