fix(계산묶음): 349-0n 노선 초안 미리보기 = [저장] 정본 — 초안 상세에 넓힌 지반 표본 · cross 단계에 서버 [확정] 보정(포장 구간 · 세월교 노면 하강 · 사토장)
- 남은 차이의 주인 = 초안 상세 지반 표본: 설계는 넓힌 표본으로 셈했는데 상세엔 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, [boolean, boolean]> = {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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<string, unknown>;
|
||||
|
||||
/** 측점 하나 — 설계 + 사토장 폭으로 다시 셈(못 세면 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;
|
||||
}
|
||||
@@ -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<string, CrossStationResult> = {};
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
chainageM: station.chainageM,
|
||||
design: (entry?.detail as Record<string, unknown> | 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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user