From ffbda88edf1de680737ad2b40ed2354ca3af8a1d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 16:36:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(=EA=B3=84=EC=82=B0=EB=AC=B6=EC=9D=8C):=2034?= =?UTF-8?q?9-0n=20=EB=85=B8=EC=84=A0=20=EC=B4=88=EC=95=88=20=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=3D=20[=EC=A0=80=EC=9E=A5]=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B8=20=E2=80=94=20=EC=B4=88=EC=95=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=97=90=20=EB=84=93=ED=9E=8C=20=EC=A7=80=EB=B0=98=20?= =?UTF-8?q?=ED=91=9C=EB=B3=B8=20=C2=B7=20cross=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=EC=97=90=20=EC=84=9C=EB=B2=84=20[=ED=99=95=EC=A0=95]=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95(=ED=8F=AC=EC=9E=A5=20=EA=B5=AC=EA=B0=84=20?= =?UTF-8?q?=C2=B7=20=EC=84=B8=EC=9B=94=EA=B5=90=20=EB=85=B8=EB=A9=B4=20?= =?UTF-8?q?=ED=95=98=EA=B0=95=20=C2=B7=20=EC=82=AC=ED=86=A0=EC=9E=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 남은 차이의 주인 = 초안 상세 지반 표본: 설계는 넓힌 표본으로 셈했는데 상세엔 20 m 표본을 실어 관 측점 구조물 면적이 잘린 지반으로 닫힘(검증_sub2 805 측점 성토 27.5 대 63.4 ㎡) — cross 단계 결과에 넓힌 표본을 싣고 초안 상세가 그 표본을 씀(서버도 넓힌 표본을 상세에 남김) - 초안 상세 제원 누가거리 = 새 측점(서버 부착과 같음) - 새 짝 logic/B06_Section_Cross_Enforce — 포장 구간 · 물넘이 기울임 · 세월교 하강 = 새 노선 관 · 구조물 자리로 판정(`pavement_ranges` · `tilt_ranges` · `ford_surface_drops` 짝) · 사토장 맨 뒤 기준 측점 하나 · 용량 이분법(`enforce_spoil_fills` 짝) · 파이썬 세 파일 머리에 짝 표시 - 검증_sub2 실측(서버 체인 실제로 돌리고 일부러 실패시켜 되살림): 25+0 부근 꺾임점 끌기 미리보기 -12,816.9 대 정본 -12,817.1 · 세월교 월류 0.22 · 0.29 넣고 -12,788.7 대 -12,789.0 · 끌기 한 번 103 ms - 시험: test_349_0n_cross_enforce_mirror 5 · test_349_0j 표본 1 더함 · 관련 114 통과 · typecheck 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- .../logic/B05_Profile_Pipeline_Detail.ts | 22 +- B06_Section/B06_Section_Engine_SpoilFill.py | 3 + B06_Section/B06_Section_Engine_Tilt.py | 2 + B06_Section/B06_Section_Router_Design.py | 6 +- .../logic/B06_Section_Cross_Enforce.ts | 202 +++++++++++++ B06_Section/logic/B06_Section_Cross_Stage.ts | 89 +++++- .../tester/helper_349_0j_draft_vs_saved.cjs | 7 + .../tester/helper_349_0n_cross_enforce.cjs | 66 +++++ .../tester/test_349_0j_draft_vs_saved.py | 8 + .../test_349_0n_cross_enforce_mirror.py | 271 ++++++++++++++++++ 10 files changed, 660 insertions(+), 16 deletions(-) create mode 100644 B06_Section/logic/B06_Section_Cross_Enforce.ts create mode 100644 resources/tester/helper_349_0n_cross_enforce.cjs create mode 100644 resources/tester/test_349_0n_cross_enforce_mirror.py diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 5df201796..1594a78c7 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -88,6 +88,19 @@ const kindOf = (station: PipelineStation): SectionStation["kind"] => ? "regular" : "irregular"; +/** 제원의 누가거리 = 새 측점(서버 부착은 따라간 관 · 구조물 자리 = 그 측점) — 옛 값이면 구조물 면적이 관 자리를 잘못 잡음(349-0n). */ +function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs { + const out: Record = { ...own }; + for (const [name, spec] of Object.entries(own)) + if ( + spec && + typeof spec === "object" && + typeof (spec as { chainage_m?: unknown }).chainage_m === "number" + ) + out[name] = { ...spec, chainage_m: chainageM }; + return out as SectionSpecs; +} + /** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */ export function draftSectionDetail( saved: SectionDetailResponse, @@ -98,7 +111,7 @@ export function draftSectionDetail( intervalM: number, ): SectionDetailResponse { const sections: CrossSection[] = ground.stations.map((station) => { - const own = specs.get(station.key) ?? {}; + const own = atChainage(specs.get(station.key) ?? {}, station.chainageM); const center = station.ground?.centerZ ?? null; const head: SectionStation = { station_id: stationIdOf(station.chainageM), @@ -126,12 +139,15 @@ export function draftSectionDetail( sample(0, center), ...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)), ]; - const design = cross?.byKey[station.key]?.detail as CrossSection["design"] | undefined; + const entry = cross?.byKey[station.key] as + { detail?: unknown; samples?: unknown[] } | undefined; + const design = entry?.detail as CrossSection["design"] | undefined; // 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j) return { ...head, ...own, - samples, + // 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n) + samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples, ...(design ? { design: { ...design } } : {}), preview: true, }; diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py index 7fd1cfffc..eff47a1be 100644 --- a/B06_Section/B06_Section_Engine_SpoilFill.py +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -16,6 +16,9 @@ ⚠ **용량이 없으면 아무것도 안 세운다** — 폭을 정할 근거가 없다. ⚠ **지반 샘플이 있는 데까지만 넓힌다.** 상한에서도 용량이 남으면 그 몫은 `unplaced_m3` 로 드러낸다 — 임의로 더 넓히지 않는다. + +짝(349-0n): `spoil_sites` · `enforce_spoil_fills` ↔ `B06_Section/logic/B06_Section_Cross_Enforce.ts` +(거울 시험 `test_349_0n_cross_enforce_mirror.py`). """ from __future__ import annotations diff --git a/B06_Section/B06_Section_Engine_Tilt.py b/B06_Section/B06_Section_Engine_Tilt.py index 6717e5d04..e67df46af 100644 --- a/B06_Section/B06_Section_Engine_Tilt.py +++ b/B06_Section/B06_Section_Engine_Tilt.py @@ -7,6 +7,8 @@ tilt_pct=…)`). 경사 = 물넘이 경사각도(%) · 맹암거 암거 경사(% `underdrain`(높이 · 경사)을 얹어 횡단도가 돌 무늬 형상을 그림(TS 짝 `common_util_structure_underdrain.ts`). 판정 폭 = 포장 범위와 같은 0.02 m(`paved_at`). +짝(349-0n): `tilt_ranges` · `tilt_args` ↔ `B06_Section/logic/B06_Section_Cross_Enforce.ts` +(거울 시험 `test_349_0n_cross_enforce_mirror.py`). """ from __future__ import annotations diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 040d79122..8adf9bd8c 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -1,4 +1,8 @@ -"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산.""" +"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산. + +짝(349-0n): `pavement_ranges` · `ford_surface_drops` · `paved_at` · `ford_drop_at` ↔ +`B06_Section/logic/B06_Section_Cross_Enforce.ts` · 거울 시험 `test_349_0n_cross_enforce_mirror.py`. +""" import json import logging diff --git a/B06_Section/logic/B06_Section_Cross_Enforce.ts b/B06_Section/logic/B06_Section_Cross_Enforce.ts new file mode 100644 index 000000000..6eec745e3 --- /dev/null +++ b/B06_Section/logic/B06_Section_Cross_Enforce.ts @@ -0,0 +1,202 @@ +/* ============================================================================= + * B06_Section_Cross_Enforce.ts + * 계산 묶음 cross 단계의 [확정] 보정(PLAN 349-0n) — 서버 `_enforce_stored_designs` 차례 그대로 + * (미교차 넓힘 → 포장 구간 → 세월교 노면 하강 → 사토장 맨 뒤). 넓힘은 `B06_Section_Cross_Stage`. + * + * facilityRanges · facilityAt 새 노선 관 · 구조물 자리 → 측점마다 포장 · 기울임 · 노면 하강. + * ⚠ ① 짝 — `B06_Section_Router_Design.py` `pavement_ranges` · `ford_surface_drops` · `paved_at` · + * `ford_drop_at` + `B06_Section_Engine_Tilt.py` `tilt_ranges` · `tilt_args`. + * spoilSites · enforceSpoilFills 사토장 기준 측점 하나 · 용량에서 폭 하나(이분법). + * ⚠ ① 짝 — `B06_Section_Engine_SpoilFill.py` `spoil_sites` · `enforce_spoil_fills`. + * 거울 시험 `resources/tester/test_349_0n_cross_enforce_mirror.py`. + * 세트 · 포장 · 맹암거 제원은 이미 있는 짝(`buildCulvertSets` · `pavementSpecsFrom` · `underdrainSpecsFrom`). + * 순수 — 화면 · fetch · 세션 없음. + * ========================================================================== */ + +import { buildCulvertSets, type CulvertPipePoint } from "@util/common_util_culvert_sets"; +import { catalogRow } from "@util/common_util_structure_catalog"; +import { + pavementSpecsFrom, + type PavementStructureInput, +} from "@util/common_util_structure_pavement"; +import { underdrainSpecsFrom, type UnderdrainInput } from "@util/common_util_structure_underdrain"; + +/** 포장 · 기울임 · 노면 하강 판정 폭(m) — 파이썬 `EDGE_TOLERANCE_M` · `_FORD_DROP_TOLERANCE_M`. */ +const EDGE_M = 0.02; + +export type FacilityStructure = PavementStructureInput & + UnderdrainInput & { structure_id?: string | null }; + +export interface FacilityRanges { + paved: Array<[number, number]>; + tilt: Array<[number, number, number | null]>; + drops: Array<[number, number]>; +} + +const num = (value: unknown): number | null => { + const parsed = typeof value === "string" && value.trim() ? Number(value) : value; + return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null; +}; + +/** 관 · 구조물 → 포장 구간 · 기울임 구간 · 세월교 하강(서버와 같은 차례 — 첫 것이 이김). */ +export function facilityRanges( + pipes: readonly CulvertPipePoint[], + structures: readonly FacilityStructure[], +): FacilityRanges { + // ponytail: 레지스트리 기본값 없이 세트를 지음 — 물넘이 폭 · 경사를 비운 관만 기본 상수(10 m · 3°) + const sets = buildCulvertSets(pipes, {}); + const pavementIds = new Set( + structures.filter((s) => catalogRow(s.type_id)?.shape === "pavement").map((s) => s.type_id), + ); + const paved: FacilityRanges["paved"] = pavementSpecsFrom(structures, pavementIds).map((s) => [ + s.start_m, + s.end_m, + ]); + const tilt: FacilityRanges["tilt"] = []; + const drops: FacilityRanges["drops"] = []; + for (const [at, spec] of sets) { + if (spec.type === "ford_pavement") { + const half = (num(spec.span_m) ?? 0) / 2; + paved.push([at - half, at + half]); + tilt.push([at - half, at + half, num(spec.slope_pct)]); + } + const depth = num(spec.overflow_depth_m) ?? 0; + if (spec.type === "ford" && depth > 0) drops.push([at, depth]); + } + for (const drain of underdrainSpecsFrom(structures)) + tilt.push([drain.chainage_m, drain.chainage_m, drain.slope_pct]); + return { paved, tilt, drops }; +} + +/** 그 측점의 포장 · 기울임 · 노면 하강 — 횡단 엔진 입력 칸 이름 그대로. */ +export function facilityAt(ranges: FacilityRanges, chainageM: number) { + const inside = (start: number, end: number) => + start - EDGE_M <= chainageM && chainageM <= end + EDGE_M; + const hit = ranges.tilt.find(([start, end]) => inside(start, end)); + return { + paved: ranges.paved.some(([start, end]) => inside(start, end)), + tilted: hit !== undefined, + tiltPct: hit?.[2] ?? null, + surfaceDropM: ranges.drops.find(([at]) => Math.abs(chainageM - at) <= EDGE_M)?.[1] ?? 0, + }; +} + +/* ── 사토장 ───────────────────────────────────────────────────────────── */ + +const SOLVE_STEPS = 24; +const MAX_PROBE_WIDTH_M = 1000; +const SIDE_AUTO = "자동(성토 쪽)"; +/** 단면 유형 → [좌, 우] 성토면인가(파이썬 `structure_face_role` 표). */ +const FILL_SIDES: Record = { + left_cut: [false, true], + right_cut: [true, false], + both_cut: [false, false], + both_fill: [true, true], +}; + +export interface SpoilSite { + structureId: string | null; + chainageM: number; + capacityM3: number; + sideOption: unknown; + slopeRatioN: unknown; + extraDistanceM: unknown; +} + +/** 배치된 사토장만 — 기준 측점 · 용량 > 0(서버 정본 읽기 = 구조물 객체 길 · 시작 측점으로 갈음 안 함). */ +export function spoilSites(structures: readonly FacilityStructure[]): SpoilSite[] { + return structures.flatMap((item) => { + if (catalogRow(item.type_id)?.shape !== "spoil") return []; + const options = (item.options ?? {}) as Record; + const chainage = num(item.chainage_m); + const capacity = num(options.capacity_m3); + if (chainage === null || capacity === null || capacity <= 0) return []; + return [ + { + structureId: item.structure_id ?? null, + chainageM: chainage, + capacityM3: capacity, + sideOption: options.side, + slopeRatioN: options.fill_slope_ratio, + extraDistanceM: options.extra_distance_m, + }, + ]; + }); +} + +/** 쌓는 쪽 — 「좌 · 우」 그대로 · 「자동」 · 빈칸 = 그 측점 성토면(좌 먼저) · 못 가르면 null. */ +export function spoilSideOf( + design: Record, + option: unknown, +): "left" | "right" | null { + const word = String(option ?? "").trim(); + if (word === "좌") return "left"; + if (word === "우") return "right"; + if (word && word !== SIDE_AUTO) return null; + const sides = FILL_SIDES[String(design.section_mode ?? "")]; + if (!sides) return null; + return sides[0] ? "left" : sides[1] ? "right" : null; +} + +type Design = Record; + +/** 측점 하나 — 설계 + 사토장 폭으로 다시 셈(못 세면 null). */ +export interface SpoilStation { + chainageM: number; + design: Design | null; + recompute: (side: "left" | "right", widthM: number, slopeRatioN: unknown) => Design | null; +} + +const round4 = (value: number) => Math.round(value * 1e4) / 1e4; + +/** 사토장 측점 설계를 다시 셈 — `stations[i].design` 을 갈아 끼움 · 바뀐 측점 수. */ +export function enforceSpoilFills(stations: SpoilStation[], sites: readonly SpoilSite[]): number { + const chainages = [...new Set(stations.map((s) => s.chainageM))].sort((a, b) => a - b); + const spanOf = (here: number) => { + const index = chainages.indexOf(here); + const left = index ? chainages[index - 1] : here; + const right = index + 1 < chainages.length ? chainages[index + 1] : here; + return (right - left) / 2; + }; + let changed = 0; + for (const site of sites) { + if (!stations.length) continue; + // 기준 측점에 가장 가까운 측점 하나(가운데면 앞 측점) + const station = stations.reduce((best, s) => { + const gap = Math.abs(s.chainageM - site.chainageM); + const bestGap = Math.abs(best.chainageM - site.chainageM); + return gap < bestGap || (gap === bestGap && s.chainageM < best.chainageM) ? s : best; + }); + const span = spanOf(station.chainageM); + const side = spoilSideOf(station.design ?? {}, site.sideOption); + const volume = (widthM: number): [number, Design | null] => { + if (side === null || widthM <= 0 || !station.design) return [0, null]; + const design = station.recompute(side, widthM, site.slopeRatioN); + return [design ? (num(design.spoil_fill_area_m2) ?? 0) * span : 0, design]; + }; + const limit = num(volume(MAX_PROBE_WIDTH_M)[1]?.spoil_fill_max_width_m) ?? 0; + if (limit <= 0) continue; + let [total, design] = volume(limit); + if (total > site.capacityM3) { + let low = 0; + let high = limit; + for (let step = 0; step < SOLVE_STEPS; step += 1) { + const mid = (low + high) / 2; + if (volume(mid)[0] < site.capacityM3) low = mid; + else high = mid; + } + [total, design] = volume(round4(high)); + } + if (!design) continue; + station.design = { + ...design, + spoil_fill_capacity_m3: round4(site.capacityM3), + spoil_fill_placed_m3: round4(total), + spoil_fill_unplaced_m3: round4(Math.max(site.capacityM3 - total, 0)), + spoil_fill_structure_id: site.structureId, + spoil_fill_extra_distance_m: site.extraDistanceM, + }; + changed += 1; + } + return changed; +} diff --git a/B06_Section/logic/B06_Section_Cross_Stage.ts b/B06_Section/logic/B06_Section_Cross_Stage.ts index ddb86a2f8..62ab354c2 100644 --- a/B06_Section/logic/B06_Section_Cross_Stage.ts +++ b/B06_Section/logic/B06_Section_Cross_Stage.ts @@ -9,6 +9,8 @@ * · 국부 연산 = 측점 열쇠마다 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 확폭 · 표준값) — 같으면 앞 결과 그대로. * · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있고 계획고가 그대로면 셈 없이 재사용 — 노선 초안의 * 첫 계획선은 바뀐 구간 앞 꼭짓점까지 움직이므로 범위만 보면 낡은 설계가 남음(349-0f). + * · [확정] 보정 ② 포장 구간 ③ 세월교 노면 하강 = 새 노선 관 · 구조물 자리(입력 pipes · structures)로 판정 · + * ④ 사토장 = 맨 뒤(`B06_Section_Cross_Enforce` · 서버 `_enforce_stored_designs` 차례 · 349-0n). * · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음. * input.cross 모양 = `CrossStageInput`(부르는 쪽이 세션 선택 · 저장분 · 구조물 맥락을 측점 열쇠로 모아 넘김). * ========================================================================== */ @@ -25,6 +27,14 @@ import { planElevationAt } from "../../B05_Profile/logic/B05_Profile_UI_Profile_ import { groundSamples } from "./B06_Section_Station_Swap"; import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground"; import type { DtmGrid } from "@util/common_util_surface_sampler"; +import type { CulvertPipePoint } from "@util/common_util_culvert_sets"; +import { + enforceSpoilFills, + facilityAt, + facilityRanges, + spoilSites, + type FacilityStructure, +} from "./B06_Section_Cross_Enforce"; export { groundSamples }; @@ -71,6 +81,11 @@ const sameSpot = (kept: CrossStationResult, station: PipelineStation, elevation: Number((kept.detail as { design_elevation_m?: number } | null)?.design_elevation_m) - elevation, ) <= 1e-4; +/** 측점 결과 + 넓힌 지반 표본(넓혔을 때만) — 상세가 그 표본을 실어야 구조물 면적이 서버처럼 넓힌 지반으로 닫힘(349-0n). */ +export interface CrossStageResult extends CrossStationResult { + samples?: unknown[]; +} + /** 단계가 받는 것 — 설계 지형 격자(ground 단계와 같은 `groundStageDeps(projectId).grid`). */ export interface CrossStageDeps { grid?: () => { grid: DtmGrid } | null; @@ -91,16 +106,27 @@ export function createCrossStage(deps: CrossStageDeps = {}) { const standardKey = JSON.stringify(input.standard); const byKey: Record = {}; last = { computed: 0, reused: 0 }; + const pipes = ctx.inputs.pipes as CulvertPipePoint[] | null; + const structures = ctx.inputs.structures as FacilityStructure[] | null; + // 포장 · 기울임 · 노면 하강 = 새 노선 자리(서버 보정 ② ③) — 입력이 없으면 저장분 측점 열쇠 값 그대로 + const ranges = + Array.isArray(pipes) && Array.isArray(structures) ? facilityRanges(pipes, structures) : null; + const optionsOf = (station: PipelineStation): CrossDesignOptions => ({ + ...optionsFor(station, input.byKey[station.key], input.standard), + ...(ranges ? facilityAt(ranges, station.chainageM) : {}), + }); for (const station of ground.stations) { const kept = ctx.prev?.byKey[station.key]; const elevation = planElevationAt(profile, station.chainageM); - if (kept && !inRange(station, ctx) && sameSpot(kept, station, elevation)) { + const spoiled = + Number((kept?.detail as Record | null)?.spoil_fill_width_m) > 0; + if (kept && !spoiled && !inRange(station, ctx) && sameSpot(kept, station, elevation)) { byKey[station.key] = { ...kept, chainageM: station.chainageM }; last.reused += 1; continue; } const samples = groundSamples(station); - const options = optionsFor(station, input.byKey[station.key], input.standard); + const options = optionsOf(station); const fingerprint = `${standardKey}|${elevation}|${JSON.stringify(samples)}|${JSON.stringify({ ...options, standard: null })}`; const hit = memo.get(station.key); if (hit?.fingerprint === fingerprint) { @@ -109,34 +135,73 @@ export function createCrossStage(deps: CrossStageDeps = {}) { continue; } let design; + let wide: { samples: unknown[] } | null = null; try { design = computeCrossDesign(samples, elevation, options); const grid = design.slope_unclosed ? deps.grid?.()?.grid : null; - const wide = - grid && - extendUnclosed( - samples, - [station.x, station.y], - station.left, - (next) => computeCrossDesign(next as typeof samples, elevation, options), - grid, - ); + wide = grid + ? extendUnclosed( + samples, + [station.x, station.y], + station.left, + (next) => computeCrossDesign(next as typeof samples, elevation, options), + grid, + ) + : null; if (wide) design = computeCrossDesign(wide.samples as typeof samples, elevation, options); } catch { continue; // 표본 부족 측점은 건너뜀(Cross_Refresh · 서버 엔진과 같은 태도) } - const result: CrossStationResult = { + const result: CrossStageResult = { key: station.key, chainageM: station.chainageM, cutAreaM2: design.cut_area_m2, fillAreaM2: design.fill_area_m2, detail: design, + ...(wide ? { samples: wide.samples } : {}), }; memo.set(station.key, { fingerprint, result }); byKey[station.key] = result; last.computed += 1; } for (const key of [...memo.keys()]) if (!byKey[key]) memo.delete(key); // 사라진 측점 + // ④ 사토장 — 맨 뒤 · 기준 측점 하나만 다시 셈(단계 기억은 사토장 전 설계) + const sites = Array.isArray(structures) ? spoilSites(structures) : []; + if (sites.length) { + const list = ground.stations.map((station) => { + const entry = byKey[station.key] as CrossStageResult | undefined; + const recompute = (side: "left" | "right", widthM: number, ratio: unknown) => { + try { + const samples = (entry?.samples ?? groundSamples(station)) as ReturnType< + typeof groundSamples + >; + return computeCrossDesign(samples, planElevationAt(profile, station.chainageM), { + ...optionsOf(station), + spoilFill: { side, widthM, slopeRatioN: ratio as number | null }, + }) as unknown as Record; + } catch { + return null; + } + }; + return { + chainageM: station.chainageM, + design: (entry?.detail as Record | undefined) ?? null, + recompute, + }; + }); + enforceSpoilFills(list, sites); + list.forEach(({ design }, index) => { + const key = ground.stations[index].key; + const entry = byKey[key]; + if (!entry || !design || design === entry.detail) return; + byKey[key] = { + ...entry, + cutAreaM2: Number(design.cut_area_m2), + fillAreaM2: Number(design.fill_area_m2), + detail: design, + }; + }); + } return { byKey }; } diff --git a/resources/tester/helper_349_0j_draft_vs_saved.cjs b/resources/tester/helper_349_0j_draft_vs_saved.cjs index 2bd52a0cf..f1b6f6eec 100644 --- a/resources/tester/helper_349_0j_draft_vs_saved.cjs +++ b/resources/tester/helper_349_0j_draft_vs_saved.cjs @@ -234,8 +234,15 @@ async function draftRun(P, grid) { !section.design || section.design !== cross.byKey[pipe.output("ground").stations[i].key]?.detail, ); + // 상세 표본 = cross 단계가 넓힌 표본(349-0n) — 구조물 면적이 넓힌 지반으로 닫힘 + const keys = pipe.output("ground").stations.map((s) => s.key); + const shownWide = shown.cross_sections.filter((section, i) => { + const samples = cross.byKey[keys[i]]?.samples; + return samples && JSON.stringify(section.samples) === JSON.stringify(samples); + }).length; const out = { outcome: run.outcome, + shownWide, totalMs: run.totalMs, line: pipe.output("route").line, curves: pipe.output("route").curves, diff --git a/resources/tester/helper_349_0n_cross_enforce.cjs b/resources/tester/helper_349_0n_cross_enforce.cjs new file mode 100644 index 000000000..03fabf7df --- /dev/null +++ b/resources/tester/helper_349_0n_cross_enforce.cjs @@ -0,0 +1,66 @@ +/* PLAN 349-0n 헬퍼 — 계산 묶음 cross 단계의 [확정] 보정(포장 구간 · 세월교 노면 하강 · 사토장). + * 진짜 TS(`B06_Section_Cross_Stage` · `B06_Section_Cross_Enforce`)를 트랜스파일해 Node 로 돌림. + * 입력 argv[2] JSON(측점 · 계획고 · 관 · 구조물 · 판정 누가거리) · 결과 = 표준출력 JSON. + * 짝 = `test_349_0n_cross_enforce_mirror.py`. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const CATALOG = require("./helper_322_catalog_module.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (request.endsWith(".json")) return require(path.join(path.dirname(full), request)); + const bare = request.replace(/\.js$/, ""); + if (bare.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${bare.slice(6)}.ts`)); + if (bare.startsWith(".")) return loadTs(path.join(path.dirname(full), `${bare}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} +const load = (...parts) => loadTs(path.join(ROOT, ...parts)); +const enforce = load("B06_Section", "logic", "B06_Section_Cross_Enforce.ts"); +const crossMod = load("B06_Section", "logic", "B06_Section_Cross_Stage.ts"); + +const ranges = enforce.facilityRanges(job.pipes, job.structures); +const at = job.probe.map((chainage) => ({ chainage, ...enforce.facilityAt(ranges, chainage) })); + +const profile = { + stations: job.stations.map((s) => ({ chainage_m: s.chainageM, plan_elevation_m: s.elevation })), + samples: job.stations.map((s) => ({ chainage_m: s.chainageM, elevation_m: s.elevation })), +}; +const ground = { stations: job.stations }; +function runStage(facilities) { + const cross = crossMod.createCrossStage(); + const started = process.hrtime.bigint(); + const out = cross.stage({ + generation: 1, + range: null, + inputs: { + cross: { standard: job.standard, byKey: job.byKey }, + pipes: facilities ? job.pipes : null, + structures: facilities ? job.structures : null, + }, + out: { profile, ground }, + prev: null, + }); + const ms = Number(process.hrtime.bigint() - started) / 1e6; + return { + ms, + byKey: Object.fromEntries(Object.entries(out.byKey).map(([k, v]) => [k, v.detail])), + }; +} + +process.stdout.write(JSON.stringify({ at, stage: runStage(true), carried: runStage(false) })); diff --git a/resources/tester/test_349_0j_draft_vs_saved.py b/resources/tester/test_349_0j_draft_vs_saved.py index 1c89f5d51..5eea91ca9 100644 --- a/resources/tester/test_349_0j_draft_vs_saved.py +++ b/resources/tester/test_349_0j_draft_vs_saved.py @@ -171,5 +171,13 @@ def test_격자_없으면_넓힘_없음(r): assert plain["final"] == pytest.approx(wide["final"]) or widened +def test_초안_상세_표본은_넓힌_표본(r): + widened = [ + s for s in r["wide"]["stations"] if s["lineEnds"] and max(map(abs, s["lineEnds"])) > 20.01 + ] + assert r["wide"]["shownWide"] == len(widened) # 349-0n — 서버도 넓힌 표본을 상세에 남김 + assert r["plain"]["shownWide"] == 0 + + def test_초안_상세_설계는_사본(r): assert r["wide"]["copied"] is True diff --git a/resources/tester/test_349_0n_cross_enforce_mirror.py b/resources/tester/test_349_0n_cross_enforce_mirror.py new file mode 100644 index 000000000..633584ece --- /dev/null +++ b/resources/tester/test_349_0n_cross_enforce_mirror.py @@ -0,0 +1,271 @@ +"""PLAN 349-0n — 계산 묶음 cross 단계의 [확정] 보정 거울 시험(서버 `_enforce_stored_designs` 짝). + +· 포장 구간 · 기울임 · 세월교 노면 하강 판정 = 서버 `paved_at` · `tilt_args` · `ford_drop_at` + (누가거리 촘촘히 · 경계 ±0.02) +· cross 단계 결과 = 서버 차례(포장 구간 → 세월교 노면 하강 → 사토장 맨 뒤) 뒤 설계 — 측점마다 + 면적 · 포장 · 기울임 · 하강 · 사토장 폭 · 면적 · 담은 양 + (용량 이분법 · 상한 · 「자동(성토 쪽)」 · 「좌」 · 못 가르는 「자동」) +· 관 · 구조물 입력이 없으면 저장분 측점 열쇠 값 그대로(보정 안 함) +짝: `helper_349_0n_cross_enforce.cjs` · TS `B06_Section/logic/B06_Section_Cross_Enforce.ts`. +""" + +from __future__ import annotations + +import json +import math +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B05_Profile.B05_Profile_Structures_Repository as structures_repo # noqa: E402 +import B06_Section.B06_Section_Engine_Tilt as tilt_mod # noqa: E402 +import B06_Section.B06_Section_Router_Design as design_mod # noqa: E402 +from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402 +from B06_Section.B06_Section_Engine_Culvert import culvert_sets_from # noqa: E402 +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from B06_Section.B06_Section_Engine_SpoilFill import enforce_spoil_fills # noqa: E402 +from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402 +from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +PIPES = [ + {"chainage_m": 110.0, "facility": "ford_bridge", "options": {"ford_height_m": 0.3}}, + { + "chainage_m": 250.0, + "facility": "ford_pavement", + "options": {"ford_width_m": 30, "ford_slope_deg": 5}, + }, + {"chainage_m": 310.0, "options": {"pipe_diameter_mm": 800}}, + {"chainage_m": 390.0, "facility": "ford_pavement", "options": {}}, # 폭 · 경사 기본값 +] +STRUCTURES = [ + StructureInstance( + structure_id="pv1", type_id="pavement_concrete", placement="interval", start_m=40, end_m=80 + ), + StructureInstance( + structure_id="ud1", + type_id="cross_underdrain", + placement="point", + chainage_m=160, + options={"culvert_slope_pct": 2, "culvert_height_m": 0.5}, + ), + StructureInstance( + structure_id="sp1", + type_id="spoil_bank", + placement="site", + chainage_m=335, + options={"capacity_m3": 300, "side": "자동(성토 쪽)"}, + ), + StructureInstance( + structure_id="sp2", + type_id="spoil_bank", + placement="site", + chainage_m=370, # 360 · 380 가운데 — 앞 측점 + options={"capacity_m3": 1e6, "side": "좌", "fill_slope_ratio": 1.5}, + ), + StructureInstance( + structure_id="sp3", + type_id="spoil_bank", + placement="site", + chainage_m=20, + options={"capacity_m3": 100, "side": "자동"}, # 못 가름 — 안 세움 + ), +] +OFFSETS = [round(-20 + 0.5 * i, 1) for i in range(81)] + + +def _z(offset: float, chainage: float) -> float: + return 100 + 0.25 * offset + 0.8 * math.sin(chainage / 37) + 0.3 * math.cos(offset / 3) + + +def _elevation(chainage: float) -> float: + return round(100 + 0.6 * math.sin(chainage / 53) + 0.002 * chainage, 4) + + +def _stations() -> list[dict]: + pipe_at = {p["chainage_m"]: f"pipe:p{i}" for i, p in enumerate(PIPES)} + chainages = sorted({20.0 * i for i in range(21)} | set(pipe_at)) + out = [] + for c in chainages: + side = [o for o in OFFSETS if o > 0] + out.append( + { + "key": pipe_at.get(c, f"grid:{round(c / 20)}"), + "kind": "pipe" if c in pipe_at else "grid", + "chainageM": c, + "x": c, + "y": 0.0, + "left": [0.0, 1.0], + "azimuthDeg": 90.0, + "planRadiusM": None, + "curveOuterSide": None, + "curveWideningM": 0, + "ground": { + "left": [{"offsetM": o, "z": _z(o, c)} for o in side], + "right": [{"offsetM": o, "z": _z(-o, c)} for o in side], + "centerZ": _z(0, c), + }, + "elevation": _elevation(c), + } + ) + return out + + +@pytest.fixture(scope="module") +def r(tmp_path_factory) -> dict: + stations = _stations() + probe = sorted( + {round(0.5 * i, 2) for i in range(841)} + | { + e + d + for e in (40, 80, 110, 160, 235, 265, 385, 395) + for d in (-0.03, -0.02, 0.02, 0.03) + } + ) + job = { + "pipes": PIPES, + "structures": [s.model_dump(mode="json") for s in STRUCTURES], + "stations": stations, + "probe": probe, + "standard": STANDARD_CROSS_SECTION, + "byKey": {s["key"]: {"sectionMode": "left_cut"} for s in stations}, + } + path = tmp_path_factory.mktemp("enforce") / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_349_0n_cross_enforce.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return {"ts": json.loads(proc.stdout), "stations": stations, "probe": probe} + + +@pytest.fixture(scope="module") +def server(r) -> list[dict]: + """서버 [확정] 차례 그대로 — 저장분(보정 전) → 포장 구간 → 세월교 하강 → 사토장.""" + patch = pytest.MonkeyPatch() + sets = culvert_sets_from(parse_pipe_points(PIPES)) + for module in (design_mod, tilt_mod): + patch.setattr(module, "load_culvert_sets", lambda _root: sets) + patch.setattr(module, "load_structures", lambda _root: (0, STRUCTURES)) + patch.setattr(structures_repo, "load_structures", lambda _root: (0, STRUCTURES)) + longitudinal = { + "design_profiles": [ + { + "samples": [ + {"chainage_m": s["chainageM"], "elevation_m": s["elevation"]} + for s in r["stations"] + ] + } + ] + } + sections = [] + for s in r["stations"]: + samples = [ + {"offset_m": o, "elevation_m": _z(o, s["chainageM"]), "valid": True} for o in OFFSETS + ] + design = compute_cross_design( + samples, + s["elevation"], + ground_type="ripping_rock", + section_mode="left_cut", + ditch_type="standard", + standard=STANDARD_CROSS_SECTION, + ) + sections.append({"chainage_m": s["chainageM"], "samples": samples, "design": design}) + root = Path("unused") + try: + design_mod.enforce_pavement_ranges(longitudinal, sections, root, STANDARD_CROSS_SECTION) + design_mod.enforce_ford_surface_drops(longitudinal, sections, root, STANDARD_CROSS_SECTION) + enforce_spoil_fills(longitudinal, sections, root, STANDARD_CROSS_SECTION) + pave = design_mod.pavement_ranges(root) + tilts = tilt_mod.tilt_ranges(root) + drops = design_mod.ford_surface_drops(root) + r["rules"] = [ + { + "paved": design_mod.paved_at(c, pave), + **tilt_mod.tilt_args(c, tilts), + "drop": design_mod.ford_drop_at(c, drops), + } + for c in r["probe"] + ] + finally: + patch.undo() + return sections + + +def test_포장_기울임_하강_판정_서버와_같음(r, server): + hits = {"paved": 0, "tilted": 0, "drop": 0} + for ts_at, py in zip(r["ts"]["at"], r["rules"], strict=True): + c = ts_at["chainage"] + assert ts_at["paved"] == py["paved"], c + assert ts_at["tilted"] == py["tilted"], c + want = py["tilt_pct"] + assert ts_at["tiltPct"] == (None if want is None else pytest.approx(want)), c + assert ts_at["surfaceDropM"] == pytest.approx(py["drop"]), c + hits["paved"] += py["paved"] + hits["tilted"] += py["tilted"] + hits["drop"] += py["drop"] > 0 + assert all(hits.values()), hits # 셋 다 실제로 걸림 + + +KEYS = ( + "cut_area_m2", + "fill_area_m2", + "surface_drop_m", + "spoil_fill_width_m", + "spoil_fill_area_m2", + "spoil_fill_replaced_fill_m2", + "spoil_fill_placed_m3", + "spoil_fill_unplaced_m3", + "spoil_fill_capacity_m3", +) + + +def test_cross_단계_결과_서버_보정_뒤와_같음(r, server): + got = r["ts"]["stage"]["byKey"] + for station, section in zip(r["stations"], server, strict=True): + a, b = got[station["key"]], section["design"] + for key in KEYS: + assert (a.get(key) or 0) == pytest.approx(b.get(key) or 0, abs=2e-3), ( + station["key"], + key, + ) + for key in ("paved", "tilted", "spoil_fill_side", "spoil_fill_structure_id"): + assert a.get(key) == b.get(key), (station["key"], key) + + +def test_사토장_세_경우(r, server): + by = {s["chainage_m"]: s["design"] for s in server} + assert by[340.0]["spoil_fill_placed_m3"] == pytest.approx(300, abs=0.5) # 이분법 — 용량만큼 + assert by[340.0]["spoil_fill_side"] == "right" # left_cut 의 성토면 + assert by[360.0]["spoil_fill_unplaced_m3"] > 0 # 상한 — 못 담은 몫 + assert not by[20.0].get("spoil_fill_width_m") # 「자동」 = 못 가름 + assert r["ts"]["stage"]["byKey"]["grid:17"]["spoil_fill_placed_m3"] == pytest.approx( + 300, abs=0.5 + ) + + +def test_관_구조물_입력이_없으면_보정_안_함(r): + carried, stage = r["ts"]["carried"]["byKey"], r["ts"]["stage"]["byKey"] + assert not carried["grid:3"].get("paved") and stage["grid:3"]["paved"] + assert not carried["pipe:p0"].get("surface_drop_m") and stage["pipe:p0"]["surface_drop_m"] + assert not carried["grid:17"].get("spoil_fill_width_m") + + +def test_단계_시간(r): + assert r["ts"]["stage"]["ms"] < 300