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:
@@ -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,
|
||||
|
||||
@@ -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) }));
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user