From 1bfe1aa6e79a70bb5c8f27b219bb5d6e1183cc5a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 12 Sep 2026 13:14:13 +0900 Subject: [PATCH] =?UTF-8?q?fix(B08):=20=ED=86=A0=EC=A0=81=ED=91=9C?= =?UTF-8?q?=EA=B0=80=20=EC=84=A4=EA=B3=84=20=EC=B8=A1=EA=B5=AC=20=EA=B0=80?= =?UTF-8?q?=EB=A6=84=EA=B0=92=EC=9D=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BD=EB=8F=84=EB=A1=9D=20=ED=95=98=EA=B3=A0=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=20=EC=9E=90=EB=A6=AC=EC=88=98=EB=A5=BC=20=ED=92=88?= =?UTF-8?q?=EC=85=88=EC=97=90=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 측구 토사·암을 절토 면적비로 다시 안분하지 않고 B06 이 낸 ditch_soil_area_m2·ditch_rock_area_m2 를 그대로 씀. 근거 ditch_split_basis 도 줄에 실음. - 설계값이 없는 저장분만 옛 안분으로 떨어지고 사유를 줄 주기에 남김. - 표기 자리수 — 단면적 ㎡ 1자리 · 체적 계열 ㎥ 2자리 · 합계 정수 (계산은 전정밀 그대로). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp --- .../B08_Quantity_Engine_EarthworkTable.py | 65 ++++++++++++++--- B08_Quantity/B08_Quantity_Router_Earthwork.py | 4 +- B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts | 42 +++++++---- resources/tester/test_b08_earthwork_table.py | 71 +++++++++++++++++-- 4 files changed, 153 insertions(+), 29 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py b/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py index 5cec5f9c..a8cfb315 100644 --- a/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py +++ b/B08_Quantity/B08_Quantity_Engine_EarthworkTable.py @@ -18,6 +18,12 @@ 계수의 유일한 정의처는 `config.config_system_design.EARTHWORK_CONVERSION_FACTORS` 이며 여기서 값을 다시 적지 않는다. +측구터파기 토사·암 — 설계가 가른 값을 그대로 읽는다 + B06 이 지반 유형 + 암반 경계선으로 이미 갈라 냈다(`ditch_soil_area_m2`· + `ditch_rock_area_m2` · 사유 `ditch_split_basis`). 여기서 다시 나누지 않는다 — + 나누면 같은 측구가 횡단도와 토적표에서 다른 숫자로 선다. 가름이 붙기 전 저장분만 + 절토 면적비 안분으로 떨어지고, 그 줄에는 사유가 남는다(`_split_ditch`). + ⚠ 숫자는 자르지 않는다 (PLAN 8-16) 품셈 1-2-2 의 소수 자리는 **표기 규칙**이다. 계산은 전정밀로 두고 화면·출력에서만 반올림한다. 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 섞지 말 것. @@ -34,6 +40,14 @@ from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # 값이 없으면 리핑암으로 본다(발파암보다 보수적으로 적은 쪽). _DEFAULT_ROCK_KIND = "ripping_rock" +# 측구 가름 근거 — 설계가 가른 값이 없어 절토 면적비로 안분했을 때만 붙는다. +# B06 가 내는 네 갈래(`no_ditch`·`soil_ground`·`rock_boundary`·`rock_ground_no_boundary`)와 +# 섞이지 않게 이름을 따로 둔다. 이 값이 표에 보이면 **설계가 가른 것이 아니다.** +_FALLBACK_BASIS = "cut_area_ratio_fallback" + +#: 폴백을 탄 측점 줄에 남기는 사유. 숫자만 봐서는 안분인지 설계값인지 알 수 없다. +_FALLBACK_NOTE = "측구 가름값이 설계에 없어 절토 토사:암 면적비로 안분함" + def _factor(kind: str) -> float: """지반유형 → 다짐 환산계수. 모르는 유형이면 토사로 본다.""" @@ -50,6 +64,11 @@ class StationArea: cut_rock_area_m2: float = 0.0 fill_area_m2: float = 0.0 ditch_area_m2: float = 0.0 + # 측구 가름은 **설계가 낸 값**이다(B06 `ditch_soil_area_m2`·`ditch_rock_area_m2`). + # `None` 은 「설계가 안 냈다」는 뜻이고 0.0 과 다르다 — 0.0 은 설계가 낸 「없음」이다. + ditch_soil_area_m2: float | None = None + ditch_rock_area_m2: float | None = None + ditch_split_basis: str | None = None cut_rock_kind: str | None = None @classmethod @@ -58,12 +77,19 @@ class StationArea: value = design.get(key) return float(value) if isinstance(value, (int, float)) else 0.0 + def optional(key: str) -> float | None: + value = design.get(key) + return float(value) if isinstance(value, (int, float)) else None + return cls( chainage_m=float(chainage_m), cut_soil_area_m2=num("cut_soil_area_m2"), cut_rock_area_m2=num("cut_rock_area_m2"), fill_area_m2=num("fill_area_m2"), ditch_area_m2=num("ditch_area_m2"), + ditch_soil_area_m2=optional("ditch_soil_area_m2"), + ditch_rock_area_m2=optional("ditch_rock_area_m2"), + ditch_split_basis=design.get("ditch_split_basis") or None, cut_rock_kind=design.get("cut_rock_kind") or None, ) @@ -92,25 +118,41 @@ class EarthworkRow: diverted_m3: float = 0.0 balance_m3: float = 0.0 cumulative_m3: float = 0.0 + # 측구를 무슨 근거로 갈랐나 — 설계가 낸 사유를 그대로 싣는다(빈 문자열은 사유 없음). + ditch_split_basis: str = "" notes: list[str] = field(default_factory=list) -def _split_ditch(area: StationArea) -> tuple[float, float]: - """측구터파기 단면적을 토사·암으로 가른다. +def _split_ditch(area: StationArea) -> tuple[float, float, str]: + """측구터파기 단면적을 토사·암으로 가른다 — **설계가 가른 값을 그대로 읽는다.** - ⚠ TODO(미결 · PLAN 8-4b) — 설계가 측구를 토사·암으로 나눠 주지 않는다(`ditch_area_m2` - 한 값뿐). 실무 토적표는 둘로 갈라 적으므로, **그 측점의 절토 토사:암 면적비로 안분**한다. - 측구는 절토부에 파므로 같은 지반을 만난다는 것이 근거다. 설계가 측구 지반을 따로 내주게 - 되면 이 함수만 갈아끼운다. + 가름의 주인은 횡단 설계다. B06 이 절토 분리와 같은 근거(지반 유형 + 암반 경계선)로 + `ditch_soil_area_m2`·`ditch_rock_area_m2` 를 내고 사유를 `ditch_split_basis` 로 + 함께 낸다. 토적표가 여기서 다시 나누면 **같은 측구가 횡단도와 토적표에서 다른 숫자로 + 선다** — 그 어긋남을 없애는 자리다. + + 못 가른 측점(암 지반인데 암반 경계선이 없음)은 설계가 **전량 암 + 사유**로 내므로 + 여기서 손보지 않는다. + + ⚠ 폴백은 **설계값이 아예 없을 때만** — 가름이 붙기 전 저장분이다. 그때만 그 측점의 + 절토 토사:암 면적비로 안분하고, 근거를 `cut_area_ratio_fallback` 으로 남겨 + 「설계가 가른 것이 아니다」가 표에 드러나게 한다. """ + if area.ditch_soil_area_m2 is not None or area.ditch_rock_area_m2 is not None: + return ( + float(area.ditch_soil_area_m2 or 0.0), + float(area.ditch_rock_area_m2 or 0.0), + area.ditch_split_basis or "", + ) + ditch = area.ditch_area_m2 if ditch <= 0: - return 0.0, 0.0 + return 0.0, 0.0, "no_ditch" soil, rock = area.cut_soil_area_m2, area.cut_rock_area_m2 total = soil + rock if total <= 0: - return ditch, 0.0 # 절토가 없으면 토사로 본다. - return ditch * soil / total, ditch * rock / total + return ditch, 0.0, _FALLBACK_BASIS # 절토가 없으면 토사로 본다. + return ditch * soil / total, ditch * rock / total, _FALLBACK_BASIS def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]: @@ -122,7 +164,7 @@ def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]: cumulative = 0.0 for station in ordered: - ditch_soil, ditch_rock = _split_ditch(station) + ditch_soil, ditch_rock, ditch_basis = _split_ditch(station) soil_factor = _factor("soil") rock_factor = _factor(station.cut_rock_kind or _DEFAULT_ROCK_KIND) row = EarthworkRow( @@ -132,7 +174,10 @@ def build_rows(stations: Iterable[StationArea]) -> list[EarthworkRow]: ditch_soil_area_m2=ditch_soil, ditch_rock_area_m2=ditch_rock, fill_area_m2=station.fill_area_m2, + ditch_split_basis=ditch_basis, ) + if ditch_basis == _FALLBACK_BASIS: + row.notes.append(_FALLBACK_NOTE) if previous is not None: distance = station.chainage_m - previous.chainage_m row.distance_m = distance diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index cc53eb50..d475296a 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -2,7 +2,9 @@ 값은 어디서 오나 측점별 단면적은 **B06 이 이미 낸 정본**이다(`cross_sections.data.design` 의 - `cut_soil_area_m2`·`cut_rock_area_m2`·`fill_area_m2`·`ditch_area_m2`). + `cut_soil_area_m2`·`cut_rock_area_m2`·`fill_area_m2`·`ditch_area_m2`, 그리고 + 측구 가름값 `ditch_soil_area_m2`·`ditch_rock_area_m2`·`ditch_split_basis`). + 설계 dict 를 통째로 `StationArea.from_design` 에 넘기므로 키가 늘어도 여기는 안 고친다. B08 은 그것을 다시 재지 않고 **평균단면적법으로 체적화만** 한다. 계산 자리 (CLAUDE.md 5장) diff --git a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts index 4f7cfc32..3e07e7f9 100644 --- a/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts +++ b/B08_Quantity/B08_Quantity_UI_EarthworkGrid.ts @@ -7,8 +7,10 @@ * 실무 산출서와 눈으로 대조를 못 한다. 열 순서·머리글 문구를 실무 시트에 맞춘다. * * ⚠ 소수 자리는 표기 규칙일 뿐이다 (PLAN 8-16) - * 서버는 전정밀 값을 준다. 자르는 것은 여기(화면)뿐이다. 실무 시트 관측 그대로 - * 단면적·체적 2자리 · 보정량계·유용토·차인·누가 1자리 · 거리 정수로 보인다. + * 서버는 전정밀 값을 준다. 자르는 것은 여기(화면)뿐이다. 자리수는 **산림사업 표준품셈**을 + * 따른다 — 단면적 ㎡ 1자리 · 입적·보정량·보정량계·유용토·차인·누가 ㎥ 2자리 · + * 체적 합계 정수 · 거리 정수. 실무 시트 관측값(단면적 2자리 등)이 아니라 품셈이 기준이다. + * ⚠ 매 단계 반올림은 오차가 쌓이므로 하지 않는다 — 자르는 곳은 이 표 한 곳뿐이다. * 원가 쪽(줄마다 원 단위 절사)과 규칙이 반대이므로 그 코드를 여기로 옮기지 말 것. * ========================================================================== */ @@ -34,6 +36,10 @@ export interface EarthworkRow { diverted_m3: number; balance_m3: number; cumulative_m3: number; + /** 측구를 무슨 근거로 갈랐나 — B06 사유 그대로. `cut_area_ratio_fallback` 이면 안분이다. */ + ditch_split_basis?: string; + /** 줄에 남은 사유(폴백 안분 등). 표 칸이 아니라 주기로 보일 값이다. */ + notes?: string[]; } /** 사면 4계열 — 계열별 (거리, 면적). 키는 `면고르기_성토면` 식으로 엔진과 같다. */ @@ -105,9 +111,14 @@ export interface EarthworkTable { settings?: QuantitySettings; } +/** 표 칸에 들어갈 수 있는 열 — 숫자 칸만 고른다(사유·주기는 표 밖이다). */ +type NumericColumnKey = { + [K in keyof EarthworkRow]-?: NonNullable extends number ? K : never; +}[keyof EarthworkRow]; + /** 열 하나. `digits` 는 **표기 자리**이며 값 자체는 자르지 않는다. */ interface Column { - key: keyof EarthworkRow; + key: NumericColumnKey; digits: number; /** 합계행에 낼지 — 단면적은 합이 뜻이 없어 비운다(실무 시트도 비어 있다). */ sum?: boolean; @@ -123,7 +134,7 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ { label: "토 사", cols: [ - { key: "cut_soil_area_m2", digits: 2 }, + { key: "cut_soil_area_m2", digits: 1 }, { key: "cut_soil_volume_m3", digits: 2, sum: true }, { key: "cut_soil_adjusted_m3", digits: 2, sum: true }, ], @@ -131,7 +142,7 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ { label: "암 석", cols: [ - { key: "cut_rock_area_m2", digits: 2 }, + { key: "cut_rock_area_m2", digits: 1 }, { key: "cut_rock_volume_m3", digits: 2, sum: true }, { key: "cut_rock_adjusted_m3", digits: 2, sum: true }, ], @@ -144,7 +155,7 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ { label: "토 사", cols: [ - { key: "ditch_soil_area_m2", digits: 2 }, + { key: "ditch_soil_area_m2", digits: 1 }, { key: "ditch_soil_volume_m3", digits: 2, sum: true }, { key: "ditch_soil_adjusted_m3", digits: 2, sum: true }, ], @@ -152,7 +163,7 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ { label: "암 석", cols: [ - { key: "ditch_rock_area_m2", digits: 2 }, + { key: "ditch_rock_area_m2", digits: 1 }, { key: "ditch_rock_volume_m3", digits: 2, sum: true }, { key: "ditch_rock_adjusted_m3", digits: 2, sum: true }, ], @@ -161,7 +172,7 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ }, { label: "", - sub: [{ label: "보정량계", cols: [{ key: "adjusted_total_m3", digits: 1, sum: true }] }], + sub: [{ label: "보정량계", cols: [{ key: "adjusted_total_m3", digits: 2, sum: true }] }], }, { label: "성 토", @@ -169,15 +180,15 @@ const GROUPS: { label: string; sub: { label: string; cols: Column[] }[] }[] = [ { label: "", cols: [ - { key: "fill_area_m2", digits: 2 }, + { key: "fill_area_m2", digits: 1 }, { key: "fill_volume_m3", digits: 2, sum: true }, ], }, ], }, - { label: "", sub: [{ label: "유 용 토", cols: [{ key: "diverted_m3", digits: 1, sum: true }] }] }, - { label: "", sub: [{ label: "차인토량", cols: [{ key: "balance_m3", digits: 1, sum: true }] }] }, - { label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 1 }] }] }, + { label: "", sub: [{ label: "유 용 토", cols: [{ key: "diverted_m3", digits: 2, sum: true }] }] }, + { label: "", sub: [{ label: "차인토량", cols: [{ key: "balance_m3", digits: 2, sum: true }] }] }, + { label: "", sub: [{ label: "누가토량", cols: [{ key: "cumulative_m3", digits: 2 }] }] }, ]; /** 사면 4계열 — 실무 토적표 오른쪽 절반(V~AI). 계열마다 (거리, 면적) 쌍이다. @@ -210,6 +221,9 @@ const SLOPE_GROUPS: { label: string; faces: { key: string; label: string }[] }[] /** 사면 계열의 소분류 머리글 — 거리(사면길이)와 면적 두 칸. */ const SLOPE_LABELS = ["거 리", "면 적"]; +/** 체적 합계 자리수 — 품셈이 정수로 정해 둔다. 거리 합계(m)도 같은 정수다. */ +const SUM_DIGITS = 0; + /** 소분류가 「단면적·입적·보정량」 삼중조일 때 붙는 3단 머리글 문구. */ const TRIPLE_LABELS = ["단면적", "입 적", "보정량"]; const PAIR_LABELS = ["단면적", "입 적"]; @@ -333,7 +347,9 @@ function buildFoot(totals: Record, slope?: SlopeTable): HTMLTabl flatColumns().forEach((column, index) => { const td = document.createElement("td"); if (index === 0) td.textContent = "계"; - else if (column.sum) td.textContent = cell(totals[column.key], column.digits); + // 합계는 **정수**다(품셈 자리수 규칙). 열마다 다른 자리수를 그대로 쓰지 않는다 — + // 합계행에 남는 소수는 실무 산출서와 모양이 어긋나는 자리다. 값은 안 자른다. + else if (column.sum) td.textContent = cell(totals[column.key], SUM_DIGITS); tr.append(td); }); // 사면은 면적만 합한다 — 거리(사면길이)는 합이 뜻이 없다. diff --git a/resources/tester/test_b08_earthwork_table.py b/resources/tester/test_b08_earthwork_table.py index 59b3ddc0..1800131c 100644 --- a/resources/tester/test_b08_earthwork_table.py +++ b/resources/tester/test_b08_earthwork_table.py @@ -115,8 +115,55 @@ def test_암은_토사와_다른_계수() -> None: ) -def test_측구_안분은_절토_토사암_비율() -> None: - """측구 지반이 따로 안 오므로 그 측점 절토 비율로 가른다(엔진 주석의 TODO 자리).""" +def test_설계가_가른_값을_그대로_읽음() -> None: + """측구 가름의 주인은 횡단 설계다 — 토적표가 절토 비율로 다시 나누면 안 된다. + + 절토 비율(3:1)로 안분하면 0.30/0.10 이 되지만, 설계는 암반 경계선으로 0.05/0.35 를 + 냈다. 그 값이 그대로 서야 횡단도와 토적표의 숫자가 같아진다. + """ + stations = [ + StationArea(chainage_m=0.0), + StationArea( + chainage_m=10.0, + cut_soil_area_m2=3.0, + cut_rock_area_m2=1.0, + ditch_area_m2=0.4, + ditch_soil_area_m2=0.05, + ditch_rock_area_m2=0.35, + ditch_split_basis="rock_boundary", + cut_rock_kind="ripping_rock", + ), + ] + row = build_rows(stations)[1] + assert row.ditch_soil_area_m2 == pytest.approx(0.05) + assert row.ditch_rock_area_m2 == pytest.approx(0.35) + assert row.ditch_split_basis == "rock_boundary" + assert row.notes == [] # 설계값을 읽었으므로 폴백 사유가 없다. + + +def test_못_가른_측점은_사유와_함께_전량_암() -> None: + """암 지반인데 암반 경계선이 없으면 설계가 전량 암으로 낸다 — 여기서 손보지 않는다.""" + stations = [ + StationArea(chainage_m=0.0), + StationArea( + chainage_m=10.0, + cut_soil_area_m2=2.0, + cut_rock_area_m2=2.0, + ditch_area_m2=0.18, + ditch_soil_area_m2=0.0, + ditch_rock_area_m2=0.18, + ditch_split_basis="rock_ground_no_boundary", + cut_rock_kind="blasting_rock", + ), + ] + row = build_rows(stations)[1] + assert row.ditch_soil_area_m2 == 0.0 # 절반씩 임의로 나누지 않는다. + assert row.ditch_rock_area_m2 == pytest.approx(0.18) + assert row.ditch_split_basis == "rock_ground_no_boundary" + + +def test_설계값이_없을_때만_절토_비율로_안분() -> None: + """가름이 붙기 전 저장분 — 폴백으로 안분하되 사유를 남겨 설계값과 구분한다.""" stations = [ StationArea(chainage_m=0.0), StationArea( @@ -130,6 +177,8 @@ def test_측구_안분은_절토_토사암_비율() -> None: row = build_rows(stations)[1] assert row.ditch_soil_area_m2 == pytest.approx(0.3) assert row.ditch_rock_area_m2 == pytest.approx(0.1) + assert row.ditch_split_basis == "cut_area_ratio_fallback" + assert row.notes and "안분" in row.notes[0] def test_값을_자르지_않을것() -> None: @@ -145,9 +194,7 @@ def test_합계행() -> None: rows = build_rows(거창_앞_네_측점()) total = totals(rows) assert total["distance_m"] == pytest.approx(30.0) - assert total["cut_soil_volume_m3"] == pytest.approx( - sum(r.cut_soil_volume_m3 for r in rows) - ) + assert total["cut_soil_volume_m3"] == pytest.approx(sum(r.cut_soil_volume_m3 for r in rows)) def test_표_모양() -> None: @@ -166,12 +213,26 @@ def test_설계결과에서_담기() -> None: "cut_rock_area_m2": 0.5, "fill_area_m2": 2.0, "ditch_area_m2": 0.18, + "ditch_soil_area_m2": 0.12, + "ditch_rock_area_m2": 0.06, + "ditch_split_basis": "rock_boundary", "cut_rock_kind": "blasting_rock", } area = StationArea.from_design(20.0, design) assert area.chainage_m == 20.0 assert area.cut_soil_area_m2 == 1.5 assert area.cut_rock_kind == "blasting_rock" + assert area.ditch_soil_area_m2 == 0.12 + assert area.ditch_rock_area_m2 == 0.06 + assert area.ditch_split_basis == "rock_boundary" + + +def test_가름이_없는_저장분은_None으로_받음() -> None: + """0.0 과 「설계가 안 냄」은 다르다 — None 이라야 폴백이 선다.""" + area = StationArea.from_design(20.0, {"cut_soil_area_m2": 1.5, "ditch_area_m2": 0.18}) + assert area.ditch_soil_area_m2 is None + assert area.ditch_rock_area_m2 is None + assert area.ditch_split_basis is None def test_측점_순서가_뒤죽박죽이어도_이정순으로() -> None: