fix(B08): 토적표가 설계 측구 가름값을 그대로 읽도록 하고 표기 자리수를 품셈에 맞춤

- 측구 토사·암을 절토 면적비로 다시 안분하지 않고 B06 이 낸
  ditch_soil_area_m2·ditch_rock_area_m2 를 그대로 씀. 근거 ditch_split_basis 도 줄에 실음.
- 설계값이 없는 저장분만 옛 안분으로 떨어지고 사유를 줄 주기에 남김.
- 표기 자리수 — 단면적 ㎡ 1자리 · 체적 계열 ㎥ 2자리 · 합계 정수 (계산은 전정밀 그대로).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
This commit is contained in:
2026-09-12 13:14:13 +09:00
co-authored by Claude Opus 5
parent d6e45bb431
commit 1bfe1aa6e7
4 changed files with 153 additions and 29 deletions
@@ -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
@@ -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장)
+29 -13
View File
@@ -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<EarthworkRow[K]> 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<string, number>, 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);
});
// 사면은 면적만 합한다 — 거리(사면길이)는 합이 뜻이 없다.
+66 -5
View File
@@ -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: