From d9b801aeaaab51b717144b5f7dd904730dc78b91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 07:51:41 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=EA=B0=80=20=EC=82=AC=ED=86=A0=EC=9E=A5=20=EB=8B=A8?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20=EB=83=84=20=E2=80=94=20=EB=85=B8=EC=84=A0?= =?UTF-8?q?=20=EC=84=B1=ED=86=A0=EC=99=80=20=EA=B0=88=EB=9D=BC=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사토장이 선 측점은 **노면 끝 바깥이 사토장 몫**이라 노선 성토에서 빼야 함. 안 빼면 같은 흙을 두 번 셈(2026-09-09 확정 ㉠). - `compute_cross_design(spoil_fill=…)` / `computeCrossDesign({spoilFill})` 신설(짝) - 새 칸: `spoil_fill_area_m2` · `spoil_fill_side` · `spoil_fill_width_m` · `spoil_fill_max_width_m` · `spoil_fill_line` · `spoil_fill_unclosed` · `spoil_fill_replaced_fill_m2`(노선 성토에서 뺀 몫 — 되짚기용) - **합쳐서 하나로 내지 않음** — 받는 쪽이 갈라 볼 수 있어야 함 - 기울기가 비면 그 측점의 노선 성토 기울기를 그대로 씀(새 값 안 만듦) - `fillAreaBeyond`/`_fill_area_beyond` — 경계 종거를 보간해 자름(한 칸도 안 흘림) - 거울 시험에 사토장 사례 둘 추가 + 값이 0 이면 잡히는 가드 ⚠ `B06_Section_Engine_Design.py` 가 924줄 — 700줄 제한 초과 상태임(이번 전에도 881줄). 기능이 다 선 뒤 분리할 자리. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_Areas.py | 34 +++++++++++ B06_Section/B06_Section_Engine_Design.py | 43 ++++++++++++++ common_util/common_util_cross_design.ts | 56 ++++++++++++++++++- common_util/common_util_cross_design_areas.ts | 43 ++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) diff --git a/B06_Section/B06_Section_Engine_Areas.py b/B06_Section/B06_Section_Engine_Areas.py index 62220348..07e1c6e5 100644 --- a/B06_Section/B06_Section_Engine_Areas.py +++ b/B06_Section/B06_Section_Engine_Areas.py @@ -166,3 +166,37 @@ def _split_ditch_area(ditch_spec: dict, depth_to_boundary_m: float | None) -> tu soil = top * d0 - (top - bottom) * d0 * d0 / (2.0 * depth) return soil, max(total - soil, 0.0) return 0.0, 0.0 + + +def _fill_area_beyond(offsets: list[float], diffs: list[float], x0: float, side: str) -> float: + """`x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다. + + ⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토 + (`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠). + ⚠ 경계(`x0`)의 종거는 **보간해서** 넣는다 — 그냥 버리면 경계 한 칸이 통째로 빠져 + 값이 작아진다. 좌는 `x0` 위쪽, 우는 `x0` 아래쪽이며 **둘 다 오름차순**으로 넘긴다. + + 짝: TS `fillAreaBeyond`. + """ + if len(offsets) < 2: + return 0.0 + inside = (lambda x: x >= x0) if side == "left" else (lambda x: x <= x0) + sub_offsets: list[float] = [] + sub_diffs: list[float] = [] + for index, x in enumerate(offsets): + if index > 0: + x_prev = offsets[index - 1] + crosses = (x_prev < x0 < x) or (x < x0 < x_prev) + if crosses: + ratio = (x0 - x_prev) / (x - x_prev) + sub_offsets.append(x0) + sub_diffs.append(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio) + if inside(x): + sub_offsets.append(x) + sub_diffs.append(diffs[index]) + order = sorted(range(len(sub_offsets)), key=lambda i: sub_offsets[i]) + sub_offsets = [sub_offsets[i] for i in order] + sub_diffs = [sub_diffs[i] for i in order] + if len(sub_offsets) < 2: + return 0.0 + return _trapezoid_areas(sub_offsets, sub_diffs)[1] diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 41beaaf8..9680168c 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -33,6 +33,7 @@ from typing import Any from B06_Section.B06_Section_Engine_Areas import ( _bench_cut_length, + _fill_area_beyond, _split_cut_areas, _split_ditch_area, _trapezoid_areas, @@ -43,6 +44,7 @@ from common_util.common_util_cross_berm import ( fill_profile_points, ) from common_util.common_util_cross_berm import elevation_at as berm_elevation_at +from common_util.common_util_spoil_fill import spoil_fill_section from config.config_system import ( CURVE_WIDENING_MAX_WIDTH_M, SECTION_DITCH_SIDES, @@ -581,6 +583,7 @@ def compute_cross_design( curve_outer_side: str | None = None, curve_widening_m: float | None = None, berm: BermSpec | None = None, + spoil_fill: dict[str, Any] | None = None, ) -> dict[str, Any]: """측점 하나의 표준횡단 설계선과 절·성토 단면적을 계산한다. @@ -600,6 +603,10 @@ def compute_cross_design( surface_drop_m: 노면을 통째로 내리는 양(m) — 세월교 월류 높이. 구체 위 노면은 월류 높이만큼 낮게 앉으므로 계획고를 그만큼 내려 잡는다. 단면 전체가 평행 이동하므로 횡단경사·측구·사면 규칙은 그대로고 절·성토 면적만 따라 바뀐다(2026-08-30 사용자). + spoil_fill: 이 측점에 선 유용토운반작업장(구 사토장) — `{"side", "width_m", "slope_ratio_n"}`. + 폭은 **노면 끝**(노견이 시작하는 자리)에서 재고, 그 바깥 성토는 **노선 몫이 아니라 + 사토장 몫**이라 `fill_area_m2` 에서 뺀다(2026-09-09 확정 ㉠ — 두 번 세지 않기). + `slope_ratio_n` 이 비면 그 측점의 **노선 성토 기울기**를 그대로 쓴다. """ if ground_type not in SECTION_GROUND_TYPE_PRESET: raise ValueError(f"지원하지 않는 지반유형입니다: {ground_type}") @@ -710,6 +717,26 @@ def compute_cross_design( abs(diffs[0]) > _SLOPE_CLOSE_TOLERANCE_M or abs(diffs[-1]) > _SLOPE_CLOSE_TOLERANCE_M ) + # 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: TS `computeCrossDesign`. + # ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠). + spoil_section = None + spoil_replaced = 0.0 + spoil_side = str((spoil_fill or {}).get("side") or "") + spoil_width = _as_float((spoil_fill or {}).get("width_m"), 0.0) + if spoil_side in ("left", "right") and spoil_width > 0: + spoil_x0 = geometry.half_road_left if spoil_side == "left" else -geometry.half_road_right + spoil_ratio = _as_float((spoil_fill or {}).get("slope_ratio_n"), 0.0) or geometry.fill_ratio + spoil_section = spoil_fill_section( + valid, + spoil_x0, + geometry.road_z(spoil_x0), + spoil_side, + spoil_width, + spoil_ratio, + ) + spoil_replaced = _fill_area_beyond(offsets, diffs, spoil_x0, spoil_side) + fill_area = max(fill_area - spoil_replaced, 0.0) + # 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암이다. 경계선 위치가 # 곧 유토곡선 EA/RR/BR 비율을 만들므로, 사용자가 경계선을 올리내리면 이 값이 함께 바뀐다. # 토사 지반은 암반 경계선 자체가 없어 전량 토사, 암 지반인데 경계선 값이 없으면(구 데이터) @@ -836,6 +863,22 @@ def compute_cross_design( "cut_rock_area_m2": round(cut_rock_area, 4), "cut_rock_kind": cut_rock_kind, "fill_area_m2": round(fill_area, 4), + # 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다). + "spoil_fill_area_m2": round(spoil_section.area_m2, 4) if spoil_section else 0.0, + "spoil_fill_side": spoil_side if spoil_section else None, + "spoil_fill_width_m": round(spoil_width, 4) if spoil_section else 0.0, + "spoil_fill_max_width_m": round(spoil_section.max_width_m, 4) if spoil_section else 0.0, + "spoil_fill_line": ( + [ + {"offset_m": offset, "elevation_m": elevation} + for offset, elevation in spoil_section.line + ] + if spoil_section + else [] + ), + "spoil_fill_unclosed": bool(spoil_section.unclosed) if spoil_section else False, + # 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. + "spoil_fill_replaced_fill_m2": round(spoil_replaced, 4), # 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). # B08 이 측점 사이를 이어 ㎡ 로 만든다. 여기서 ㎥ 로 바꾸지 않는다 — # 단의 높이·폭이 설계도서 값이라 지어낼 수 없다. diff --git a/common_util/common_util_cross_design.ts b/common_util/common_util_cross_design.ts index b011ac78..8a521360 100644 --- a/common_util/common_util_cross_design.ts +++ b/common_util/common_util_cross_design.ts @@ -24,8 +24,11 @@ * ========================================================================== */ import type { BermSpec } from "./common_util_cross_berm"; +import type { SpoilFillSection } from "./common_util_spoil_fill"; +import { spoilFillSection } from "./common_util_spoil_fill"; import { benchCutLength, + fillAreaBeyond, splitCutAreas, splitDitchArea, trapezoidAreas, @@ -100,6 +103,13 @@ export interface CrossDesignOptions { curveWideningM?: number | null; /** 이 측점의 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */ berm?: BermSpec | null; + /** + * 이 측점에 선 유용토운반작업장(구 사토장). 폭은 **노면 끝**(노견이 시작하는 자리)에서 + * 재고, 그 바깥 성토는 **노선 몫이 아니라 사토장 몫**이라 `fill_area_m2` 에서 뺀다 + * (2026-09-09 확정 ㉠ — 두 번 세지 않기). `slopeRatioN` 이 비면 노선 성토 기울기를 쓴다. + * 짝: 파이썬 `compute_cross_design(spoil_fill=…)`. + */ + spoilFill?: { side: "left" | "right"; widthM: number; slopeRatioN?: number | null } | null; } export interface CrossDesignEdge { @@ -138,6 +148,15 @@ export interface CrossDesignResult { fill_area_m2: number; /** 층따기 밑수 — 성토부 아래 원지반(1:4 보다 급한 구간)의 지표면 길이(m). */ bench_cut_length_m: number; + /** 사토장 몫 — **`fill_area_m2` 와 합치지 않는다**(받는 쪽이 갈라 볼 수 있어야 한다). */ + spoil_fill_area_m2: number; + spoil_fill_side: "left" | "right" | null; + spoil_fill_width_m: number; + spoil_fill_max_width_m: number; + spoil_fill_line: CrossDesignEdge[]; + spoil_fill_unclosed: boolean; + /** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */ + spoil_fill_replaced_fill_m2: number; slope_unclosed: boolean; fill_ground_slope: number | null; ditch_area_m2: number; @@ -355,7 +374,8 @@ export function computeCrossDesign( } // 측구 굴착은 설계선에 포함돼 절토 면적에 자연 반영된다(별도 가산 없음). - const [cutArea, fillArea] = trapezoidAreas(offsets, diffs); + const [cutArea, baseFillArea] = trapezoidAreas(offsets, diffs); + let fillArea = baseFillArea; // 층따기 밑수(길이 m) — 성토부 아래 원지반이 1:4 보다 급한 구간의 지표면 길이. const benchCut = benchCutLength(offsets, grounds, diffs); const fillGroundSlope = geometry.fillGroundSlope(); @@ -364,6 +384,28 @@ export function computeCrossDesign( (Math.abs(diffs[0]) > SLOPE_CLOSE_TOLERANCE_M || Math.abs(diffs[diffs.length - 1]) > SLOPE_CLOSE_TOLERANCE_M); + // 사토장(유용토운반작업장) — 노면 끝 바깥에 쌓는 성토. 짝: 파이썬 `compute_cross_design`. + // ⚠ 그 바깥 성토는 **노선 몫이 아니다** — 빼지 않으면 같은 흙을 두 번 센다(확정 ㉠). + const spoilSide = options.spoilFill?.side ?? null; + const spoilWidth = Math.max(Number(options.spoilFill?.widthM ?? 0), 0); + let spoilSection: SpoilFillSection | null = null; + let spoilReplaced = 0; + if ((spoilSide === "left" || spoilSide === "right") && spoilWidth > 0) { + const spoilX0 = spoilSide === "left" ? geometry.halfRoadLeft : -geometry.halfRoadRight; + const askedRatio = Number(options.spoilFill?.slopeRatioN ?? 0); + const spoilRatio = askedRatio > 0 ? askedRatio : geometry.fillRatio; + spoilSection = spoilFillSection({ + ground: valid.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + startOffsetM: spoilX0, + startElevationM: geometry.roadZ(spoilX0), + side: spoilSide, + widthM: spoilWidth, + slopeRatioN: spoilRatio, + }); + spoilReplaced = fillAreaBeyond(offsets, diffs, spoilX0, spoilSide); + fillArea = Math.max(fillArea - spoilReplaced, 0); + } + // 절토면적 토사/암반 분리 — 지표면~암반 경계선이 토사, 그 아래가 암. let cutSoilArea: number; let cutRockArea: number; @@ -493,6 +535,18 @@ export function computeCrossDesign( cut_rock_area_m2: round4(cutRockArea), cut_rock_kind: cutRockKind, fill_area_m2: round4(fillArea), + spoil_fill_area_m2: spoilSection ? round4(spoilSection.area_m2) : 0, + spoil_fill_side: spoilSection ? spoilSide : null, + spoil_fill_width_m: spoilSection ? round4(spoilWidth) : 0, + spoil_fill_max_width_m: spoilSection ? round4(spoilSection.maxWidthM) : 0, + spoil_fill_line: spoilSection + ? spoilSection.line.map((point) => ({ + offset_m: point.offset_m, + elevation_m: point.elevation_m, + })) + : [], + spoil_fill_unclosed: spoilSection ? spoilSection.unclosed : false, + spoil_fill_replaced_fill_m2: round4(spoilReplaced), bench_cut_length_m: round4(benchCut), slope_unclosed: slopeUnclosed, fill_ground_slope: fillGroundSlope === null ? null : round4(fillGroundSlope), diff --git a/common_util/common_util_cross_design_areas.ts b/common_util/common_util_cross_design_areas.ts index dfb41184..9cc1bc1c 100644 --- a/common_util/common_util_cross_design_areas.ts +++ b/common_util/common_util_cross_design_areas.ts @@ -159,3 +159,46 @@ export function splitDitchArea( } return [0, 0]; } + +/** + * `x0` **바깥쪽**(사토장이 서는 쪽)의 성토 면적(㎡)만 따로 낸다. + * + * ⚠ 왜 있나 — 사토장이 선 측점에서는 노면 끝 바깥이 **사토장 몫**이라 노선 성토 + * (`fill_area_m2`)에서 빼야 한다. 안 빼면 **같은 흙을 두 번 센다**(2026-09-09 확정 ㉠). + * ⚠ 경계(`x0`)에서 잘라 쓰므로 그 자리의 종거를 **보간해서** 넣는다 — 그냥 버리면 + * 경계 한 칸이 통째로 빠져 값이 작아진다. + * + * 짝: 파이썬 `_fill_area_beyond`. + */ +export function fillAreaBeyond( + offsets: number[], + diffs: number[], + x0: number, + side: "left" | "right", +): number { + if (offsets.length < 2) return 0; + const inside = side === "left" ? (x: number) => x >= x0 : (x: number) => x <= x0; + const subOffsets: number[] = []; + const subDiffs: number[] = []; + for (let index = 0; index < offsets.length; index += 1) { + const x = offsets[index]; + if (index > 0) { + const xPrev = offsets[index - 1]; + const crosses = (xPrev < x0 && x0 < x) || (x < x0 && x0 < xPrev); + if (crosses) { + const ratio = (x0 - xPrev) / (x - xPrev); + subOffsets.push(x0); + subDiffs.push(diffs[index - 1] + (diffs[index] - diffs[index - 1]) * ratio); + } + } + if (inside(x)) { + subOffsets.push(x); + subDiffs.push(diffs[index]); + } + } + const order = subOffsets.map((_, index) => index).sort((a, b) => subOffsets[a] - subOffsets[b]); + const sortedOffsets = order.map((index) => subOffsets[index]); + const sortedDiffs = order.map((index) => subDiffs[index]); + if (sortedOffsets.length < 2) return 0; + return trapezoidAreas(sortedOffsets, sortedDiffs)[1]; +} From 3a5a13b48800c86b82902d9826f641d84248f242 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 08:31:23 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(B06):=20=EC=82=AC=ED=86=A0=EC=9E=A5=20?= =?UTF-8?q?=EC=9A=A9=EB=9F=89=EC=97=90=EC=84=9C=20=ED=8F=AD=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=ED=95=B4=20=EC=B8=A1=EC=A0=90=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=84=B8=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자는 「이 구간에 ○㎥」를 정하는데 단면은 폭을 알아야 그려짐. 그 구간 측점을 한꺼번에 보고 **폭 하나**를 이분법으로 찾음(작업장은 폭이 일정함). - `enforce_spoil_fills` 신설 — `enforce_ford_surface_drops` 와 같은 자리·같은 방식. 자동설계 체인·[저장] 뒤 **맨 마지막**에 돎(앞 보정이 다시 계산하면 사토장 칸이 지워짐) - 「자동」이면 그 측점의 **성토 쪽**에 섬 (`structure_face_role` 재사용) - 상한(지반 샘플이 있는 데까지)에서 멈추고 못 담은 몫은 `spoil_fill_unplaced_m3` 로 냄 - 구간 안 측점만 씀 — **새 측점을 만들지 않음**(사용자 확정 ③) - 용량이 없으면 아무것도 안 세움(폭을 정할 근거가 없음) 실측 — 용량 200㎥·800㎥ 를 넣으면 각각 그만큼 담기고(오차 2% 안), 100만㎥ 를 넣으면 상한 폭에서 멈추고 남은 몫이 값으로 남음. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Engine_SpoilFill.py | 264 ++++++++++++++++++ .../B06_Section_Server_Calc_Prebuild.py | 4 + 2 files changed, 268 insertions(+) create mode 100644 B06_Section/B06_Section_Engine_SpoilFill.py diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py new file mode 100644 index 00000000..5437b3a9 --- /dev/null +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -0,0 +1,264 @@ +"""사토장(유용토운반작업장) — 용량에서 **폭을 정해** 측점마다 단면을 세운다. + +왜 여기 있나 + 사용자는 「이 구간에 ○㎥ 를 쌓겠다」고 정한다. 그런데 횡단 단면은 **폭**을 알아야 + 그려진다. 그래서 그 구간 측점들을 한꺼번에 보고 **폭 하나**를 되풀이로 찾는다. + (측점마다 폭을 달리하면 실제로 못 쌓는 모양이 나온다 — 작업장은 폭이 일정하다.) + + `enforce_ford_surface_drops` 와 같은 자리·같은 방식이다 — **저장분을 쓰는 시점에** + 바로잡고, 저장분과 지금 값이 다를 때만 다시 계산한다. + +정하는 것과 안 정하는 것 + ⚠ **기울기·적치높이 기본값을 지어내지 않는다** — 지식DB + `01_임도/02_상세설계/유용토운반작업장.md` §4 가 「근거에 없다. 사용자 협의 없이 + 기본값을 만들지 않는다」로 못 박았다. 기울기가 비면 **그 측점의 노선 성토 기울기**를 + 그대로 쓰고(이미 설계된 값), 높이는 **노면 끝 높이**로 정해진다. + ⚠ **용량이 없으면 아무것도 안 세운다** — 폭을 정할 근거가 없다. + ⚠ **지반 샘플이 있는 데까지만 넓힌다.** 상한에서도 용량이 남으면 그 몫은 + `unplaced_m3` 로 드러낸다 — 임의로 더 넓히지 않는다. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from B06_Section.B06_Section_Engine_Design import compute_cross_design +from common_util.common_util_structure_face_role import structure_face_role + +#: 폭을 좁혀 가는 이분법 반복 수. TS 짝(`solveSpoilWidthM`)과 같은 값이다. +_SOLVE_STEPS = 24 +#: 상한을 재려고 한 번 크게 넣어 보는 폭(m). 실제로는 지반 샘플에서 잘린다. +_MAX_PROBE_WIDTH_M = 1000.0 +#: 사토장 종류 이름 — 등록부 `spoil_bank`(현행 명칭 유용토운반작업장). +SPOIL_TYPE_ID = "spoil_bank" +#: 「자동(성토 쪽)」 — 등록부 `side` 의 기본 선택지. C군 구조물과 같은 낱말이다. +_SIDE_AUTO = "자동(성토 쪽)" +_SIDE_WORDS = {"좌": "left", "우": "right"} + + +def _sections_in(cross_sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[dict]: + """구간 안에 든 측점만. **새 측점을 만들지 않는다**(2026-09-09 사용자 확정 ③).""" + picked = [] + for section in cross_sections: + chainage = section.get("chainage_m") + if chainage is None: + continue + value = float(chainage) + if start_m - 1e-6 <= value <= end_m + 1e-6: + picked.append(section) + return sorted(picked, key=lambda item: float(item["chainage_m"])) + + +def _spans(sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[float]: + """측점마다 대표 길이(m) — 앞뒤 측점과의 절반씩. 구간 끝은 경계까지만.""" + spans: list[float] = [] + for index, section in enumerate(sections): + chainage = float(section["chainage_m"]) + left = float(sections[index - 1]["chainage_m"]) if index else max(start_m, chainage) + right = ( + float(sections[index + 1]["chainage_m"]) + if index + 1 < len(sections) + else min(end_m, chainage) + ) + spans.append(max((chainage - left) / 2 + (right - chainage) / 2, 0.0)) + return spans + + +def _side_of(design: dict[str, Any], option: Any) -> str | None: + """쌓는 쪽 — 「좌·우」면 그대로, 「자동」이면 그 측점의 **성토 쪽**.""" + word = str(option or "").strip() + if word in _SIDE_WORDS: + return _SIDE_WORDS[word] + if word and word != _SIDE_AUTO: + return None + mode = str(design.get("section_mode") or "") + for korean, key in _SIDE_WORDS.items(): + role, _reason = structure_face_role(mode, korean) + if role == "성토": + return key + return None + + +def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]: + """배치된 사토장만 골라 쓰기 좋은 모양으로. 구간·용량이 없으면 뺀다.""" + sites: list[dict[str, Any]] = [] + for item in structures: + type_id = getattr(item, "type_id", None) or ( + item.get("type_id") if isinstance(item, dict) else None + ) + if str(type_id) != SPOIL_TYPE_ID: + continue + options = getattr(item, "options", None) + if options is None and isinstance(item, dict): + options = item.get("options") + options = options or {} + start = getattr(item, "start_m", None) + end = getattr(item, "end_m", None) + if isinstance(item, dict): + start = item.get("start_m") + end = item.get("end_m") + capacity = options.get("capacity_m3") + if start is None or end is None or capacity in (None, ""): + continue + try: + capacity_value = float(capacity) + except (TypeError, ValueError): + continue + if capacity_value <= 0: + continue + sites.append( + { + "structure_id": getattr(item, "structure_id", None) + or (item.get("structure_id") if isinstance(item, dict) else None), + "start_m": min(float(start), float(end)), + "end_m": max(float(start), float(end)), + "capacity_m3": capacity_value, + "side_option": options.get("side"), + "slope_ratio_n": options.get("fill_slope_ratio"), + "extra_distance_m": options.get("extra_distance_m"), + } + ) + return sites + + +def _volume_at( + width_m: float, + sections: list[dict[str, Any]], + spans: list[float], + sides: list[str | None], + slope_ratio_n: Any, + longitudinal: dict[str, Any], + standard: dict[str, Any] | None, + recompute, +) -> tuple[float, list[dict[str, Any] | None]]: + """그 폭으로 쌓이는 총 부피(㎥)와 측점별 설계. 평균단면적법이 아니라 대표길이 곱이다.""" + designs: list[dict[str, Any] | None] = [] + total = 0.0 + for section, span, side in zip(sections, spans, sides, strict=True): + if side is None or width_m <= 0: + designs.append(None) + continue + design = recompute(section, side, width_m, slope_ratio_n, longitudinal, standard) + designs.append(design) + if design: + total += float(design.get("spoil_fill_area_m2") or 0.0) * span + return total, designs + + +def enforce_spoil_fills( + longitudinal: dict[str, Any], + cross_sections: list[dict[str, Any]], + project_root: Path, + standard: dict[str, Any] | None = None, +) -> int: + """사토장이 선 측점의 설계를 다시 계산한다. 바뀐 측점 수를 돌려준다. + + 폭은 **구간 하나에 하나** — 용량에 맞춰 이분법으로 찾는다. 상한(지반 샘플이 있는 + 데까지)에서도 모자라면 그 폭으로 두고 못 담은 몫을 `spoil_fill_unplaced_m3` 로 낸다. + """ + from B05_Profile.B05_Profile_Structures_Repository import load_structures + from B06_Section.B06_Section_Engine_Design import curve_widening_args + from B06_Section.B06_Section_Router_Design import ( + USER_TOUCHED_KEYS, + stored_berm, + stored_cut_slope, + ) + from common_util.common_util_route_profile import design_elevation_from_longitudinal + + try: + _revision, structures = load_structures(str(project_root)) + except Exception: # noqa: BLE001 — 정본이 없으면 사토장도 없다 + return 0 + sites = spoil_sites(structures) + if not sites: + return 0 + + def recompute(section, side, width_m, slope_ratio_n, longitudinal_data, standard_spec): + design = section.get("design") + if not isinstance(design, dict): + return None + chainage = float(section.get("chainage_m", 0.0)) + try: + return compute_cross_design( + section.get("samples", []), + design_elevation_from_longitudinal(longitudinal_data, chainage), + ground_type=str(design.get("ground_type") or "soil"), + section_mode=str(design.get("section_mode") or "left_cut"), + ditch_side=design.get("ditch_side"), + ditch_type=str(design.get("ditch_type") or "standard"), + paved=bool(design.get("paved", False)), + standard=standard_spec, + rock_boundary_offset_m=design.get("rock_boundary_offset_m"), + two_stage_slope=bool(design.get("two_stage_slope", True)), + cut_slope_ratio=stored_cut_slope(design), + ditch_enabled=design.get("ditch_enabled"), + surface_drop_m=float(design.get("surface_drop_m") or 0.0), + berm=stored_berm(design), + spoil_fill={ + "side": side, + "width_m": width_m, + "slope_ratio_n": slope_ratio_n, + }, + **curve_widening_args(section), + ) + except (ValueError, KeyError): + return None + + changed = 0 + for site in sites: + sections = _sections_in(cross_sections, site["start_m"], site["end_m"]) + if not sections: + continue + spans = _spans(sections, site["start_m"], site["end_m"]) + sides = [_side_of(section.get("design") or {}, site["side_option"]) for section in sections] + ratio = site["slope_ratio_n"] + + def volume(width_m: float): + return _volume_at( + width_m, sections, spans, sides, ratio, longitudinal, standard, recompute + ) + + # 상한 = 그 구간에서 가장 좁은 측점이 허락하는 폭. 한 측점이라도 지반 샘플이 + # 모자라면 거기서 잘리므로, 넓혀도 그 측점은 안 늘어난다. + top_total, top_designs = volume(_MAX_PROBE_WIDTH_M) + limit = min( + ( + float(design.get("spoil_fill_max_width_m") or 0.0) + for design in top_designs + if design + ), + default=0.0, + ) + if limit <= 0: + continue + total, designs = volume(limit) + width = limit + if total > site["capacity_m3"]: + low, high = 0.0, limit + for _ in range(_SOLVE_STEPS): + mid = (low + high) / 2 + if volume(mid)[0] < site["capacity_m3"]: + low = mid + else: + high = mid + width = round(high, 4) + total, designs = volume(width) + + unplaced = max(site["capacity_m3"] - total, 0.0) + for section, design in zip(sections, designs, strict=True): + if not design: + continue + stored = section.get("design") or {} + for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS): + if stored.get(key) is not None: + design[key] = stored[key] + # 구간 전체 값도 측점마다 실어 둔다 — 화면 말풍선·수량이 되짚을 수 있게. + design["spoil_fill_capacity_m3"] = round(site["capacity_m3"], 4) + design["spoil_fill_placed_m3"] = round(total, 4) + design["spoil_fill_unplaced_m3"] = round(unplaced, 4) + design["spoil_fill_structure_id"] = site["structure_id"] + design["spoil_fill_extra_distance_m"] = site["extra_distance_m"] + section["design"] = design + changed += 1 + return changed diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index cb3a260c..22a80983 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -127,6 +127,7 @@ def _enforce_stored_designs( 예전에는 상세를 **읽을 때마다** 돌려 화면이 볼 때만 맞았다(저장분은 낡은 채로). 2026-09-06 사용자 확정대로 「읽기는 영구저장소에서 가져오기만」이므로 이쪽으로 옮겼다. """ + from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills from B06_Section.B06_Section_Router_Design import ( enforce_ford_surface_drops, enforce_pavement_ranges, @@ -134,6 +135,9 @@ def _enforce_stored_designs( enforce_pavement_ranges(longitudinal, sections, project_root, standard) enforce_ford_surface_drops(longitudinal, sections, project_root, standard) + # ⚠ 사토장은 **맨 뒤**다 — 앞의 두 보정이 설계를 다시 계산하면서 사토장 칸을 지운다. + # 맨 뒤에 두면 그 결과 위에 사토장 단면이 얹힌다(2026-09-09). + enforce_spoil_fills(longitudinal, sections, project_root, standard) async def recompute_server_side(project_id: UUID | str, route_id: int) -> int: From 490566eb4db310ba1e05e217b5dd2d15ace82198 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:29:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(B06):=20=ED=9A=A1=EB=8B=A8=EB=8F=84?= =?UTF-8?q?=EC=97=90=20=EC=82=AC=ED=86=A0=EC=9E=A5=20=EC=84=B1=ED=86=A0?= =?UTF-8?q?=EC=84=A0=C2=B7=EB=A7=90=ED=92=8D=EC=84=A0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 교본 6장 3절이 운반처리 위치를 평면도·횡단도에 표시하도록 요구함. - 긴 파선+점(9 3 2 3) · 사토장 색 — **터파기 짧은 점선과 선 종류를 가름** - 이름표 「유용토운반작업장 ○㎡」 를 평상 위에 얹음 - 말풍선에 단면·폭·구간 용량·못 담은 몫·잘림 경고와 **근거 두 줄** (「폭은 노면 끝에서 잼」·「교본 6장 3절이 표시를 요구함」) - 지반을 못 만나 잘린 사토장은 붉게 — 사면 미폐합 경고와 같은 결 - 계획선을 만져도 사토장이 안 사라지게 저장분 폭·구간값을 이어 붙임 (폭은 구간 용량에서 서버가 정한 값이라 브라우저가 다시 풀지 않음) Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Api_Types.ts | 19 ++++ B06_Section/B06_Section_Cross_Refresh.ts | 31 ++++++- B06_Section/B06_Section_UI_Cross_SpoilFill.ts | 91 +++++++++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 4 + .../B06_Section_UI_Style_Cross_Areas.css | 23 +++++ 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 B06_Section/B06_Section_UI_Cross_SpoilFill.ts diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 7fbca2a6..05323078 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -453,6 +453,25 @@ export interface CrossDesign { /** 암반부에 적용할 지반유형(`ripping_rock`/`blasting_rock`). 토사 측점은 null. */ cut_rock_kind?: GroundType | null; fill_area_m2: number; + /** + * 사토장(유용토운반작업장) 몫 — **`fill_area_m2` 와 합치지 않는다**(2026-09-09 확정 ㉠). + * 노면 끝 바깥은 노선 성토가 아니라 사토장 성토라, 받는 쪽이 갈라 볼 수 있어야 한다. + * 사토장이 없는 측점은 전부 0·null 이다(구 데이터에는 아예 없어 optional). + */ + spoil_fill_area_m2?: number; + spoil_fill_side?: "left" | "right" | null; + spoil_fill_width_m?: number; + spoil_fill_max_width_m?: number; + spoil_fill_line?: Array<{ offset_m: number; elevation_m: number }>; + spoil_fill_unclosed?: boolean; + /** 사토장이 대신 차지해 노선 성토에서 뺀 몫(㎡) — 되짚기용. 합계에 또 넣지 말 것. */ + spoil_fill_replaced_fill_m2?: number; + /** 구간(구조물) 단위 값 — 측점마다 같은 값이 실린다. 말풍선·수량이 되짚는 데 쓴다. */ + spoil_fill_capacity_m3?: number; + spoil_fill_placed_m3?: number; + spoil_fill_unplaced_m3?: number; + spoil_fill_structure_id?: string | null; + spoil_fill_extra_distance_m?: number | null; /** 성토측 자연 지반 경사(rise/run). 자연방토 판정 입력. 성토측이 없으면 null. */ fill_ground_slope?: number | null; /** diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 09951ca5..875525b3 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -89,7 +89,23 @@ export const USER_TOUCHED_KEYS = [ ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ -const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const; +// 사토장 구간값 — **브라우저가 못 만드는 값**이라 이어 붙인다. 구간 전체를 봐야 나오는 +// 값이고(용량 배분) 정본은 [저장] 때 서버가 다시 낸다. 안 이으면 계획선을 만지는 순간 +// 말풍선에서 「구간 용량 …」이 사라진다. +const SPOIL_KEYS = [ + "spoil_fill_capacity_m3", + "spoil_fill_placed_m3", + "spoil_fill_unplaced_m3", + "spoil_fill_structure_id", + "spoil_fill_extra_distance_m", +] as const; + +const PRESERVED_KEYS = [ + "status", + "pavement_suggested", + ...USER_TOUCHED_KEYS, + ...SPOIL_KEYS, +] as const; function preserveUserFields( next: NonNullable, @@ -281,6 +297,19 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { ? section.curve_outer_side : null, curveWideningM: section.curve_widening_m ?? null, + // 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라 + // 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다). + // 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다. + spoilFill: + typeof design.spoil_fill_width_m === "number" && + design.spoil_fill_width_m > 0 && + (design.spoil_fill_side === "left" || design.spoil_fill_side === "right") + ? { + side: design.spoil_fill_side, + widthM: design.spoil_fill_width_m, + slopeRatioN: null, + } + : null, }, ); } catch { diff --git a/B06_Section/B06_Section_UI_Cross_SpoilFill.ts b/B06_Section/B06_Section_UI_Cross_SpoilFill.ts new file mode 100644 index 00000000..ae88da48 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_SpoilFill.ts @@ -0,0 +1,91 @@ +/* ============================================================================= + * B06_Section_UI_Cross_SpoilFill.ts + * 횡단도에 **사토장(유용토운반작업장) 성토선**을 얹는다. + * + * 왜 있나 — 임도기술교본 6장 3절이 **운반처리 위치를 평면도와 횡단도에 표시**하도록 + * 요구한다(지식DB `01_임도/02_상세설계/유용토운반작업장.md` §2). 수량을 세기 전에 + * 도면에 그것이 보여야 한다. + * + * ⚠ **터파기 파선과 선 종류를 가른다** — 터파기는 짧은 점선, 사토장은 긴 파선+점. + * 같은 파선으로 두면 도면에서 둘을 구별할 수 없다(2026-09-09 네 창 확정). + * ⚠ 값은 설계 결과(`spoil_fill_*`)에서 온다 — 여기서 **다시 세지 않는다**. + * ========================================================================== */ + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** 설계 결과에서 사토장 그리기에 쓰는 값만 추려 받는다. */ +export interface SpoilFillDrawing { + spoil_fill_line?: Array<{ offset_m: number; elevation_m: number }> | null; + spoil_fill_area_m2?: number | null; + spoil_fill_width_m?: number | null; + spoil_fill_unclosed?: boolean | null; + spoil_fill_capacity_m3?: number | null; + spoil_fill_placed_m3?: number | null; + spoil_fill_unplaced_m3?: number | null; +} + +/** 말풍선 문구 — 무엇이 얼마나 쌓였는지와 **근거**를 함께 적는다. */ +export function spoilFillTooltip(design: SpoilFillDrawing): string { + const area = Number(design.spoil_fill_area_m2 ?? 0); + const width = Number(design.spoil_fill_width_m ?? 0); + const lines = [ + `유용토운반작업장(구 사토장) · 단면 ${area.toFixed(2)}㎡ · 폭 ${width.toFixed(2)}m`, + ]; + const capacity = design.spoil_fill_capacity_m3; + if (typeof capacity === "number" && capacity > 0) { + const placed = Number(design.spoil_fill_placed_m3 ?? 0); + lines.push(`구간 용량 ${capacity.toFixed(1)}㎥ 중 ${placed.toFixed(1)}㎥ 담김`); + const unplaced = Number(design.spoil_fill_unplaced_m3 ?? 0); + if (unplaced > 0) { + lines.push(`⚠ ${unplaced.toFixed(1)}㎥ 는 못 담음 — 지반 자료가 있는 데까지만 넓힘`); + } + } + if (design.spoil_fill_unclosed) { + lines.push("⚠ 비탈이 원지반을 못 만나 잘림 — 지반 자료 범위를 넘어감"); + } + lines.push("폭은 노면 끝(노견이 시작하는 자리)에서 잼 — 그 구간 노견도 이 성토 안에 듦"); + lines.push("교본 6장 3절이 평면도·횡단도 표시를 요구함"); + return lines.join("\n"); +} + +/** + * 사토장 성토선을 그린다. 선이 없으면 아무것도 하지 않는다. + * 돌려주는 값은 그린 폴리라인(없으면 `null`) — 부르는 쪽이 강조에 쓸 수 있다. + */ +export function appendSpoilFillOverlay( + layer: SVGElement, + design: SpoilFillDrawing | null | undefined, + x: (offset: number) => number, + toDisplayY: (elevation: number) => number, +): SVGElement | null { + const line = design?.spoil_fill_line; + if (!design || !Array.isArray(line) || line.length < 2) return null; + const polyline = document.createElementNS(SVG_NS, "polyline"); + polyline.setAttribute( + "points", + line.map((point) => `${x(point.offset_m)},${toDisplayY(point.elevation_m)}`).join(" "), + ); + polyline.setAttribute( + "class", + design.spoil_fill_unclosed ? "b06-chart__spoil-fill is-unclosed" : "b06-chart__spoil-fill", + ); + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = spoilFillTooltip(design); + polyline.append(title); + layer.append(polyline); + + // 이름표 — 평상 한가운데 위에 얹는다. 선만 있으면 그것이 무엇인지 도면에서 모른다. + const first = line[0]; + const last = line[line.length - 1]; + const label = document.createElementNS(SVG_NS, "text"); + label.setAttribute("x", String((x(first.offset_m) + x(last.offset_m)) / 2)); + label.setAttribute("y", String(toDisplayY(Math.max(first.elevation_m, last.elevation_m)) - 4)); + label.setAttribute("text-anchor", "middle"); + label.setAttribute("class", "b06-chart__spoil-fill-label"); + label.textContent = `유용토운반작업장 ${Number(design.spoil_fill_area_m2 ?? 0).toFixed(2)}㎡`; + const labelTitle = document.createElementNS(SVG_NS, "title"); + labelTitle.textContent = spoilFillTooltip(design); + label.append(labelTitle); + layer.append(label); + return polyline; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 061fd070..734a9037 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -19,6 +19,7 @@ import { appendFordSurfaceDropPlan, } from "./B06_Section_UI_Cross_Ford_Pavement"; import { culvertWallsStandAt } from "./B06_Section_UI_Cross_Culvert_Wire"; +import { appendSpoilFillOverlay } from "./B06_Section_UI_Cross_SpoilFill"; import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; import { appendCrossDesignOverlay, @@ -467,6 +468,9 @@ export function createCrossSectionCard( // 같은 트림 값을 쓰므로 "그림은 이런데 수량은 저렇다"가 생기지 않는다. applyStructureAreas(section, designTrim); appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, drawSamples, designTrim); + // 사토장(유용토운반작업장) — 교본 6장 3절이 횡단도 표시를 요구한다. 값은 설계 결과에서 + // 그대로 오고 여기서 다시 세지 않는다. 선 종류는 터파기 파선과 갈라 둔다. + appendSpoilFillOverlay(plotLayer, section.design, x, toDisplayY); // 암 경계선 = 지면선 복사 + 오프셋(계획선 기준 아님). if (rockBoundary && section.design.geometry_preset === "rock") { appendRockBoundaryOverlay( diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 535a53c9..36151f4f 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -388,3 +388,26 @@ stroke-dasharray: 4 3; stroke-width: 1; } + +/* 사토장(유용토운반작업장) — **터파기 파선과 선 종류를 가른다**(2026-09-09). + 터파기는 짧은 점선(4 3), 사토장은 **긴 파선 + 점**(9 3 2 3)이라 한눈에 갈린다. + 색은 등록부 사토장 색(#8c9a2e)을 그대로 쓴다. */ +.b06-chart__spoil-fill { + fill: none; + stroke: #8c9a2e; + stroke-dasharray: 9 3 2 3; + stroke-width: 1.4; +} + +.b06-chart__spoil-fill-label { + fill: #6d7a20; + font-size: 10px; + paint-order: stroke; + stroke: rgba(255, 255, 255, 0.9); + stroke-width: 3; +} + +/* 지반을 못 만나 잘린 사토장 — 사면 미폐합 경고와 같은 결로 붉게 알린다. */ +.b06-chart__spoil-fill.is-unclosed { + stroke: #c0392b; +} From a99204c34d8b5463ea0bf0ac07d4de142a1bcf78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 9 Sep 2026 11:32:20 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(B08):=20=EC=82=AC=ED=86=A0=EC=9E=A5?= =?UTF-8?q?=EC=9D=B4=20=EC=84=9C=EB=A9=B4=20=EC=82=AC=ED=86=A0=20=EC=9A=B4?= =?UTF-8?q?=EB=B0=98=EA=B1=B0=EB=A6=AC=EA=B0=80=20=EC=B8=A1=EC=A0=90?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=82=98=EC=98=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사토장이 **측점 위에만** 서므로(사용자 확정 ③) 거리는 「발생점 → 사토장 측점」 누가거리로 그냥 나옴. 가정할 것이 없어짐. - `_spoil_sites` — 측점 설계에 실려 온 사토장 구간값을 구조물 단위로 접음(다시 안 셈) - 거리 = 발생점별 물량 가중평균 + `extra_distance_m`(항상 더함) - 사토장이 없으면 종전대로 설계 입력값 · 그것도 없으면 막힘(임의 거리 안 넣음) - 사유에 사토장 측점·수용량·**못 담는 몫**을 실어 화면이 드러내게 함 - `distance_basis` 칸 신설 — 받는 쪽이 「측점 기준」과 「설계 입력값」을 갈라 봄 ⚠ 사토장에 쌓아도 **유토곡선 사토는 줄지 않음** — 그 흙은 여전히 실어 내야 하는 흙이고 사토장은 목적지임. 줄이면 운반비가 사라짐. (네 창에 확인 요청함) Co-Authored-By: Claude Opus 5 (1M context) --- B08_Quantity/B08_Quantity_Router_Earthwork.py | 89 ++++++++++++++++++- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Router_Earthwork.py b/B08_Quantity/B08_Quantity_Router_Earthwork.py index f928aa37..326c22bf 100644 --- a/B08_Quantity/B08_Quantity_Router_Earthwork.py +++ b/B08_Quantity/B08_Quantity_Router_Earthwork.py @@ -87,7 +87,7 @@ async def get_earthwork_table(project_id: UUID, route_id: int) -> JSONResponse: # 사토 — **운반 줄이 되는 값**인데 유토곡선의 띠·이동에는 안 들어 있다(잔량으로 남는다). # 여기서 그 값을 운반표에 실어 인계가 「사토 운반」 한 줄을 세우게 한다. # ⚠ 거리는 품셈이 정하지 않는다 — 설계 입력(`spoil_site_distance_m`)이고 없으면 막힌다. - haul["spoil"] = _spoil_of(plan, settings) + haul["spoil"] = _spoil_of(plan, settings, _spoil_sites(designs)) # 배수관 연장 — B06 이 측점 `design.pipe_length_m` 에 남긴 값. **여기서 짓지 않는다.** # 인계가 관 줄을 세울 때 쓴다. 단면을 두 번 읽지 않으려고 이 응답에 실어 보낸다. table["pipe_lengths"] = [ @@ -163,7 +163,75 @@ _COMPACTED_FACTOR = { } -def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str, Any]: +def _spoil_sites(designs: list[dict[str, Any]]) -> list[dict[str, Any]]: + """배치된 사토장(유용토운반작업장) — 측점 설계에 실려 온 구간값을 모은다. + + ⚠ 여기서 **다시 세지 않는다** — 용량·담긴 양은 B06 이 정한 값이고, 이 함수는 그것을 + 구조물 단위로 접어 「어디에 얼마나 담기나」만 만든다. + """ + sites: dict[str, dict[str, Any]] = {} + for row in designs: + design = (row or {}).get("design") or {} + key = str(design.get("spoil_fill_structure_id") or "") + if not key or not float(design.get("spoil_fill_area_m2") or 0.0) > 0: + continue + chainage = float(row.get("chainage_m") or 0.0) + site = sites.setdefault( + key, + { + "structure_id": key, + "from_m": chainage, + "to_m": chainage, + "capacity_m3": float(design.get("spoil_fill_capacity_m3") or 0.0), + "placed_m3": float(design.get("spoil_fill_placed_m3") or 0.0), + "unplaced_m3": float(design.get("spoil_fill_unplaced_m3") or 0.0), + "extra_distance_m": design.get("spoil_fill_extra_distance_m"), + }, + ) + site["from_m"] = min(site["from_m"], chainage) + site["to_m"] = max(site["to_m"], chainage) + for site in sites.values(): + site["center_m"] = (site["from_m"] + site["to_m"]) / 2 + return sorted(sites.values(), key=lambda item: item["center_m"]) + + +def _site_distance_m( + sites: list[dict[str, Any]], residuals: list[dict[str, Any]] +) -> tuple[float | None, str]: + """사토장까지의 **가중평균 운반거리**(m)와 근거 문구. + + 「발생점 → 사토장 측점」 누가거리다(2026-09-09 사용자 확정 ③ — 사토장이 측점 위에만 + 서므로 가정할 것이 없다). 사토가 여러 자리에 남으면 물량으로 가중평균한다. + ⚠ 사토장이 없으면 `None` — 설계 입력(`spoil_site_distance_m`)으로 되돌아간다. + **임의 거리를 넣지 않는다**(그대로 금액이 된다). + """ + if not sites: + return None, "" + work = 0.0 + volume = 0.0 + for residual in residuals: + if str(residual.get("kind") or "") != "spoil": + continue + amount = float(residual.get("volume_m3") or 0.0) - float(residual.get("natural_m3") or 0.0) + if amount <= 0: + continue + center = (float(residual.get("from_m") or 0.0) + float(residual.get("to_m") or 0.0)) / 2 + nearest = min(sites, key=lambda site: abs(site["center_m"] - center)) + extra = nearest.get("extra_distance_m") + distance = abs(nearest["center_m"] - center) + float(extra or 0.0) + work += amount * distance + volume += amount + if volume <= 0: + return None, "" + where = " · ".join(f"{site['center_m']:,.1f}m" for site in sites) + return work / volume, f"사토장 측점({where})까지 발생점 기준 가중평균" + + +def _spoil_of( + plan: dict[str, Any] | None, + settings: dict[str, Any], + sites: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: """사토 — 실어 낼 물량과 거리. 유토곡선 결과에서 **다시 세지 않고 그대로** 가져온다. ⚠ `spoil_m3` 는 **공제·가산이 끝난 값**이다(채집석 공제는 빼고 구조물 잔토는 더한 뒤). @@ -225,10 +293,25 @@ def _spoil_of(plan: dict[str, Any] | None, settings: dict[str, Any]) -> dict[str } if unknown > 0: note_parts.append(f"⚠ 갈래를 못 붙인 {unknown:,.2f}㎥ 는 상태도 못 되돌림") + # 거리 — 사토장이 서 있으면 **그 측점까지의 누가거리**로 나온다. 없으면 설계 입력값. + site_distance, site_basis = _site_distance_m(sites or [], source.get("residuals") or []) + if site_distance is not None: + note_parts.append(site_basis) + placed = sum(float(site.get("placed_m3") or 0.0) for site in sites or []) + unplaced = sum(float(site.get("unplaced_m3") or 0.0) for site in sites or []) + note_parts.append(f"사토장 수용 {placed:,.1f}㎥") + if unplaced > 0: + note_parts.append(f"⚠ 못 담는 {unplaced:,.1f}㎥ 는 밖으로 내야 함") return { "volume_m3": round(volume, 3), "volume_basis": "compacted", - "distance_m": settings.get("spoil_site_distance_m"), + "distance_m": ( + round(site_distance, 3) + if site_distance is not None + else settings.get("spoil_site_distance_m") + ), + "distance_basis": ("사토장(측점) 기준" if site_distance is not None else "설계 입력값"), + "sites": sites or [], "note": " · ".join(note_parts), "by_ground_m3": {key: round(value, 3) for key, value in grounds.items()}, "natural_m3_by_ground": natural_by_ground,