feat(계산묶음): 346-1 · 346-2 남은 단계 붙임 — cross · masshaul · haul · drainage · corridor · 노선 초안 동안 유토곡선 = 새 노선 횡단

- design_pipeline_page: 단계 다섯 한 줄씩(부르기만) · 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) · haul · corridor 는 노선 초안 동안만 · 열 때 한 번 셈(발자국 바로 보임)
- 노선 초안 동안 종단도 = 새 노선 상세(pipelineDetail — 지반 · 측점 · 횡단 면적 → 유토곡선) · 저장 노선 횡단 다시 셈 안 함
- 횡단 입력 = 저장 상세의 측점 입력을 측점 열쇠로(design_pipeline_cross · 넘기는 칸 = 서버 CARRY_INPUT_KEYS 와 같음)
- 저장 관 목록(좌표 · id) = 관 입력 바탕 · 첫 계산도 저장 노선 기준으로 좌표 따라감(createFollowStage)
- 노선 초안 동안 계획고 = alignment-draft 노선 0 칸(DRAFT_ROUTE_ID) · 초안 버리면 비움 · [저장] 이음 = sub5
- 상단측 짝(logic/B05_Profile_Pipeline_Detail uphillSide ↔ Engine_Sections_Core uphill_side) · 측점 열쇠 = common_util_station_key 한 벌
- 시험 test_346_2_pipeline_cross_page 14 · 옛 시험 둘 새 꼴에 맞춤(317 · 230_2) · 346-1 도움 파일 창구 셋 가짜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-16 12:32:11 +09:00
co-authored by Claude Opus 5.5
parent 5795f359cc
commit a35d49b4e2
13 changed files with 1098 additions and 86 deletions
@@ -97,7 +97,7 @@ out.viewOf = {
buildAlignment: () => null,
store: { edits: () => ({}) },
projectId: "p",
pipelineGrade: () => undefined, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음
pagePipeline: { pipelineGrade: () => undefined }, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음
scheduleCrossPreview: () => s.previews++,
draw: () => s.draws++,
root: inPopup(),
@@ -68,8 +68,13 @@ STUBS["B05_Profile_Api_Structures"] = {
};
STUBS["B05_Profile_Api_TerrainGrid"] = {
designTerrainGridNow: () => local,
loadDesignTerrainGrid: async () => local,
groundStageDeps: () => ({ grid: () => local, remote: async () => (remoteCalls++, []) }),
};
// 346-2 붙임 뒤 창구(관 목록 · 표준단면 · 흙 배분) — 이 시험은 노선 · 종단만 봄
STUBS["B04_PreProcess_Api_Fetch"] = { fetchDetailPipePoints: async () => ({ pipe_points: [] }) };
STUBS["B06_Section_UI_Standard_Panel"] = { effectiveStandardCross: () => null };
STUBS["B05_Profile_Api_HaulPlan"] = { createHaulStage: () => async () => null };
const pipeMod = load("A00_Common", "design", "design_pipeline.ts");
const pageMod = load("A00_Common", "design", "design_pipeline_page.ts");
@@ -0,0 +1,328 @@
/* PLAN 346-1 · 346-2 붙임 단위검증 헬퍼 — 페이지 묶음에 cross · masshaul · haul · drainage · corridor 를
* 붙인 것. 진짜 TS(`design_pipeline_page` · `_cross` · 단계 파일 전부)를 그 자리에서 트랜스파일해 Node 로
* 돌림. 서버 창구(노선 · 구조물 · 관 목록 · 지형 격자 · 표준단면 · 흙 배분 fetch)만 가짜.
* 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_346_2_pipeline_cross_page.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"));
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
}
globalThis.window = {
sessionStorage: memoryStorage(),
localStorage: memoryStorage(),
setTimeout,
clearTimeout,
};
// 흙 배분 서버 — 늦게 답함 · 끊기면 버림
const haul = { calls: 0, aborted: 0, answered: 0 };
globalThis.fetch = (url, init) =>
new Promise((resolve, reject) => {
haul.calls += 1;
const timer = setTimeout(() => {
haul.answered += 1;
resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) });
}, job.haulDelayMs);
init.signal.addEventListener("abort", () => {
clearTimeout(timer);
haul.aborted += 1;
reject(new Error("aborted"));
});
});
const STUBS = {};
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;
const key = Object.keys(STUBS).find((name) => request.endsWith(name));
if (key) return STUBS[key];
if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" };
if (request.startsWith("@util/"))
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.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 sampler = load("common_util", "common_util_surface_sampler.ts");
const bytes = fs.readFileSync(process.argv[3]);
const grid = sampler.parseDtmGrid(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length),
);
const local = { grid, design: "las" };
STUBS["B05_Profile_Api_Replan"] = {
analysisTerrain: () => "las",
fetchRoutePlan: async () => ({
planned: job.line,
curves: [],
min_radius_m: job.minRadius,
station_snap: false,
}),
};
STUBS["B05_Profile_Api_Structures"] = {
fetchStructures: async () => ({ structures: [] }),
fetchStructureTypes: async () => job.types,
};
STUBS["B05_Profile_Api_TerrainGrid"] = {
loadDesignTerrainGrid: async () => local,
groundStageDeps: () => ({ grid: () => local, remote: async () => [] }),
};
STUBS["B04_PreProcess_Api_Fetch"] = {
fetchDetailPipePoints: async () => ({
pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })),
}),
};
STUBS["B06_Section_UI_Standard_Panel"] = { effectiveStandardCross: () => job.standard };
const pipeMod = load("A00_Common", "design", "design_pipeline.ts");
const pageMod = load("A00_Common", "design", "design_pipeline_page.ts");
const stationsMod = load("A00_Common", "design", "design_pipeline_stations.ts");
const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts");
const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts");
const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts");
const detailMod = load("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts");
const alignment = load("B05_Profile", "B05_Profile_UI_Profile_Alignment.ts");
const editMod = load("B05_Profile", "B05_Profile_UI_Profile_Edit.ts");
const page = load("A00_Common", "b_page_state.ts");
const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const runs = () => globalThis.__aisloPipeline?.runs ?? [];
async function settle(count) {
for (let i = 0; i < 400; i += 1) {
const last = runs()[runs().length - 1];
if (runs().length >= count && last && last.outcome !== "running") return last;
await tick(5);
}
throw new Error("묶음이 끝나지 않음");
}
const n = job.nodes.length;
const draftOf = (nodes) => ({
nodes,
curveOn: Array(n).fill(true),
curveRadius: Array(n).fill(null),
curveLock: Array(n).fill(null),
curveArc: Array(n).fill(null),
stationSnap: false,
});
const shift = (index, dx, dy) =>
job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node));
/** 저장 기준 — 저장 노선 지반 첫 계획선(관 측점 포함 · 서버 정책 그대로). */
async function savedBaseOf() {
const seed = pipeMod.createDesignPipeline({
stages: {
route: routeGround.createRouteStage({
minRadiusM: () => job.minRadius,
savedLine: () => ({ line: job.line, curves: [] }),
}),
stations: stationsMod.createStationsStage({
intervalM: job.interval,
types: () => job.types,
}),
ground: routeGround.createGroundStage({ grid: () => local, remote: async () => [] }),
},
});
await seed.start({ route: null, alignment: null, cross: null, pipes: job.pipes, structures: [] });
const ground = seed.output("ground");
seed.dispose();
const samples = ground.longitudinal.chainageM.map((c, i) => ({
chainage_m: c,
elevation_m: ground.longitudinal.z[i],
}));
const profile = gradeGround.groundProfile(samples);
return {
policy: job.policy,
onlyExplicitCurves: false,
basePvi: gradeGround.groundFollowingBasePvi({
samples,
stations: ground.stations.map((s) => s.chainageM),
policy: job.policy,
}),
chainage: profile.chainage,
ground: profile.ground,
stations: ground.stations.map((s) => ({
station_id: grade.stationIdOf(s.chainageM),
chainage_m: s.chainageM,
})),
};
}
async function run() {
const P = "proj-3462";
window.localStorage.setItem("frd_current_project_id", P); // 지금 프로젝트(노선 0 칸 자리)
const savedBase = await savedBaseOf();
const seedRuns = runs().length;
const adopted = [];
let redraws = 0;
let corridor = 0;
const drainage = [];
const link = {
pipelineSource: () => ({
base: savedBase,
edits: alignment.emptyEdits(),
detail: job.detail,
routeId: 7,
}),
adoptPipeline: (base, edits, draft) => adopted.push({ draft, saved: base === savedBase }),
redraw: () => (redraws += 1),
side: {
pipelineStage: (ctx) => {
drainage.push(ctx.inputs.route !== null);
return null;
},
},
};
const bridge = pageMod.routeDraftBridge(P, link, {
stationIntervalM: job.interval,
onCorridorStale: () => (corridor += 1),
});
pageMod.pipelineEarthwork(P, { conversion: job.conversion });
const pipe = pageMod.pagePipeline(P);
const commits = [];
pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`));
// ① 열 때 한 번 — 저장 노선 · 발자국 · 뒤 단계는 초안 없으니 3D 낡음 · 흙 배분 안 함
const openRun = await settle(seedRuns + 1);
await tick(job.haulDelayMs + 20);
const open = {
outcome: openRun.outcome,
stages: openRun.stages,
footprint: pipe.output("footprint")?.records.length ?? 0,
crossCount: Object.keys(pipe.output("cross")?.byKey ?? {}).length,
stations: pipe.output("ground").stations.length,
carried: Object.keys(pipe.input("cross")?.byKey ?? {}).length,
corridor,
haulCalls: haul.calls,
detail: pageMod.pipelineDetail(P),
pipeKeys: pipe
.output("ground")
.stations.filter((s) => s.kind === "pipe")
.map((s) => [s.key, s.chainageM]),
};
// ② 늦은 응답 버림 — 끝점 옮김(흙 배분 기다리는 중) → 바로 첫 점 옮김
bridge.edited(draftOf(shift(n - 1, 6, -4)));
const runA = await settle(runs().length + 1);
await tick(2);
bridge.edited(draftOf(shift(0, -5, 3)));
const runB = await settle(runs().length + 1);
await tick(job.haulDelayMs + 30);
const genA = runA.generation;
const genB = runB.generation;
const ground = pipe.output("ground");
const profile = pipe.output("profile");
const shown = pageMod.pipelineDetail(P);
const mass = pipe.output("masshaul");
const moved = {
run: {
outcome: runB.outcome,
stages: runB.stages,
totalMs: runB.totalMs,
sources: runB.sources,
},
line: pipe.output("route").line,
curves: pipe.output("route").curves,
stations: ground.stations.map((s) => ({
key: s.key,
kind: s.kind,
chainage: s.chainageM,
uphill: detailMod.uphillSide(s),
elevation: alignment.planElevationAt(profile, s.chainageM),
planRadius: s.planRadiusM,
outer: s.curveOuterSide,
widening: s.curveWideningM,
cut: pipe.output("cross").byKey[s.key]?.cutAreaM2 ?? null,
fill: pipe.output("cross").byKey[s.key]?.fillAreaM2 ?? null,
})),
finalCumulative: mass?.haulInput?.final_cumulative_m3 ?? null,
footprint: pipe.output("footprint")?.records.length ?? 0,
detail: shown && {
sections: shown.cross_sections.length,
designs: shown.cross_sections.filter((s) => s.design).length,
longitudinal: shown.longitudinal.samples.length,
stationIds: shown.longitudinal.stations.length,
profileMatches: shown.longitudinal.profile_alignment === profile,
},
alignmentDraft: editMod.readAlignmentDraft(pageMod.DRAFT_ROUTE_ID),
adoptedLast: adopted[adopted.length - 1],
redraws,
corridor,
drainage,
};
const late = {
genA,
genB,
haulCommits: commits.filter((c) => c.startsWith("haul@")),
haul: { ...haul },
};
// ③ 계획고 고침(초안 동안) — 노선 0 칸에 남음
const edits = {
station_offsets: { [alignment.chainageKey(savedBase.stations[3].chainage_m)]: 0.4 },
curve_radii: {},
};
const draftEdits = editMod.createProfileEditStore(
pageMod.DRAFT_ROUTE_ID,
alignment.emptyEdits(),
() => undefined,
);
draftEdits.replace(edits);
pageMod.pipelineGrade(P, edits);
const gradeRun = await settle(runs().length + 1);
const gradeDraft = {
pending: editMod.pendingAlignment(pageMod.DRAFT_ROUTE_ID),
sources: gradeRun.sources,
stages: Object.keys(gradeRun.stages),
};
// ④ 열 때로(초안 버림) — 노선 0 칸 · 새 노선 상세 비움
bridge.edited(null);
await settle(runs().length + 1);
await tick(20);
const reverted = {
detail: pageMod.pipelineDetail(P),
alignmentDraft: editMod.readAlignmentDraft(pageMod.DRAFT_ROUTE_ID),
pending: editMod.pendingAlignment(pageMod.DRAFT_ROUTE_ID),
adoptedLast: adopted[adopted.length - 1],
};
pageMod.disposePagePipeline(P);
page.clearDrafts(P);
return { open, moved, late, gradeDraft, reverted, n };
}
run()
.then((out) => {
process.stdout.write(JSON.stringify(out));
process.exit(0);
})
.catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});
+2 -1
View File
@@ -54,7 +54,8 @@ def test_same_stale_key_schedules_once(result: dict) -> None:
def test_draw_and_button_wiring() -> None:
panel = _read("A00_Common", "design", "design_profile.ts")
assert "onStaleDesigns: crossPreview.scheduleIfStale" in panel
# 노선 초안 동안(346-2 붙임)은 계산 묶음 cross 를 기다림 — 그 밖은 같은 낡음 한 번만 다시 셈
assert "crossPreview.scheduleIfStale(key)" in panel and "onStaleDesigns: (key) =>" in panel
render = _read("B05_Profile", "B05_Profile_UI_Profile_Render.ts")
# 다시 셈 뒤에도 남은 낡음은 기다림 끝 — 곡선은 그리고 그 측점만 표시(342-1)
assert 'const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";' in render
@@ -0,0 +1,285 @@
"""PLAN 346-1 · 346-2 붙임 — 페이지 계산 묶음에 cross · masshaul · haul · drainage · corridor.
· 열 때 한 번(저장 노선 · 발자국 · 저장 횡단 입력이 측점 열쇠로 모임)
초안 없으면 3D 낡음 · 흙 배분 안 함
· 노선 점 옮김 한 번에 cross · footprint · masshaul 까지 · 단계 합 0.3 s 안
· 누가토량 = 서버 엔진(generate_sections · compute_cross_design)으로
같은 노선을 셈한 값과 같은 자리수
· 상단측(단면 유형 기본값) = 서버 `uphill_side` 짝(거울)
· 늦은 응답 버림(흙 배분 끊김 · 옛 차례 haul 안 들어옴)
· 저장 관 목록이 좌표로 새 노선을 따라감
· 노선 초안 동안 계획고 = alignment-draft 노선 0 칸 · 초안을 버리면 비움
짝: `helper_346_2_pipeline_cross_page.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜).
"""
from __future__ import annotations
import csv
import json
import math
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
import pytest
from shapely.geometry import LineString, Point
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
design_ground_following_profile,
)
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from common_util.common_util_drainage_pipes import parse_pipe_points # noqa: E402
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
from common_util.common_util_structure_station import plan_stations # noqa: E402
from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # 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="프론트엔드 의존성 미설치",
)
PROJECT = ROOT / "storage/1/3/6d3b2812-6d5a-4168-a932-40e4ae303d5a" # 검증_sub7
REAL_GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz"
REAL_NODES = PROJECT / "B05_Profile/route/planned_route_nodes.csv"
MIN_RADIUS_M = 12.0
INTERVAL_M = 20.0
BUDGET_MS = 300.0
ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")}
def _types() -> list[dict]:
return [
{
"type_id": t.type_id,
"group": t.group,
"placement": t.placement,
"managed_by": t.managed_by,
}
for t in structure_type_map().values()
]
def _line(nodes: list[list[float]]) -> list[list[float]]:
built = build_planned_polyline(
[tuple(n) for n in nodes],
min_radius_m=MIN_RADIUS_M,
simplify=False,
curve_flags=[True] * len(nodes),
radii=[None] * len(nodes),
)
return [list(v) for v in built.vertices]
def _fake(folder: Path) -> tuple[Path, list[list[float]]]:
"""가짜 격자 — 칸 폭 고르지 않음 · 기운 땅(상단측이 갈림)."""
rng = np.random.default_rng(3462)
x = (np.cumsum(rng.uniform(0.95, 1.1, 300)) + 1000.0).astype(np.float32)
y = (np.cumsum(rng.uniform(0.95, 1.1, 200)) + 2000.0).astype(np.float32)
xx, yy = np.meshgrid(x.astype(np.float64), y.astype(np.float64))
z = 500 + 0.12 * (xx - 1000) - 0.35 * (yy - 2000) + 2.5 * np.sin(xx / 11.0)
npz = folder / "dtm_fake.npz"
np.savez(npz, x=x, y=y, z=z.astype(np.float32), valid_mask=np.ones(z.shape, dtype=bool))
return npz, [[1020.0, 2030.0], [1100.0, 2050.0], [1170.0, 2120.0], [1270.0, 2150.0]]
def _real(_folder: Path) -> tuple[Path, list[list[float]]]:
if not (REAL_GRID.is_file() and REAL_NODES.is_file()):
pytest.skip("검증_sub7 없음")
with REAL_NODES.open(encoding="utf-8") as handle:
return REAL_GRID, [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)]
def _pipes(line: list[list[float]]) -> list[dict]:
shape = LineString(line)
out = []
for ratio, pid in ((0.3, "p-a"), (0.62, "p-b")):
at = round(shape.length * ratio, 3)
point = shape.interpolate(at)
out.append({"id": pid, "chainage_m": at, "x": point.x, "y": point.y, "source": "user"})
return out
def _sections(line, sampler, pipes, curves=None) -> dict:
extras = tuple(
(float(s["chainage_m"]), str(s["title"]))
for s in plan_stations(parse_pipe_points(pipes), [])
)
return core.generate_sections(
np.asarray(line, dtype=np.float64),
sampler,
core.SectionGenerationOptions(station_interval_m=INTERVAL_M, extra_stations=extras),
design_curves=curves or None,
)
def _saved_detail(data: dict) -> dict:
"""저장 상세 — 측점마다 넘기는 입력만 담은 설계(지반 유형)."""
sections = [
{**s, "design": {"ground_type": "ripping_rock", "design_elevation_m": s["center_z"]}}
for s in data["cross_sections"]
]
return {"longitudinal": {**data["longitudinal"]}, "cross_sections": sections}
def _run(case) -> dict:
with tempfile.TemporaryDirectory() as folder:
npz, nodes = case(Path(folder))
line = _line(nodes)
sampler = DtmGridSampler.from_npz(npz)
pipes = _pipes(line)
saved = _sections(line, sampler, pipes)
options = resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None)
policy = design_ground_following_profile(saved["longitudinal"], options)[0]["policy"]
job = {
"nodes": nodes,
"line": line,
"minRadius": MIN_RADIUS_M,
"policy": policy,
"types": _types(),
"interval": INTERVAL_M,
"pipes": pipes,
"standard": STANDARD_CROSS_SECTION,
"conversion": ONES,
"detail": _saved_detail(saved),
"haulDelayMs": 40,
}
path = Path(folder) / "job.json"
path.write_text(json.dumps(job), encoding="utf-8")
grid = Path(folder) / "grid.bin"
grid.write_bytes(dtm_grid_bytes(npz))
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_346_2_pipeline_cross_page.cjs"), str(path), str(grid)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=180,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
# 서버 엔진으로 같은 노선(옮긴 뒤) — 측점 · 지반 · 횡단 면적 · 상단측
moved = out["moved"]
ts_pipes = [
{"id": s["key"].split(":", 1)[1], "chainage_m": s["chainage"], "source": "user"}
for s in moved["stations"]
if s["kind"] == "pipe"
]
server = _sections(moved["line"], sampler, ts_pipes, moved["curves"])
out["server"] = [
{
"chainage": s["chainage_m"],
"uphill": s.get("uphill_side"),
"samples": s["samples"],
"planRadius": s.get("plan_radius_m"),
"outer": s.get("curve_outer_side"),
"widening": s.get("curve_widening_m"),
}
for s in server["cross_sections"]
]
out["pipes"] = pipes
return out
@pytest.fixture(scope="module", params=[_fake, _real], ids=["가짜", "검증_sub7"])
def r(request) -> dict:
return _run(request.param)
def test_열_때_한_번_발자국_저장_횡단_입력(r):
o = r["open"]
assert o["outcome"] == "done"
assert {"cross", "footprint", "masshaul"} <= set(o["stages"])
assert o["crossCount"] == o["stations"] and o["footprint"] > 2
assert o["carried"] == o["stations"] # 저장 측점마다 입력이 측점 열쇠로
assert o["corridor"] == 0 and o["haulCalls"] == 0 and o["detail"] is None
assert sorted(k for k, _ in o["pipeKeys"]) == ["pipe:p-a", "pipe:p-b"]
def test_노선_옮김_한_번에_유토곡선까지_시간(r):
m = r["moved"]
run = m["run"]
assert run["outcome"] == "done" and run["sources"] == ["route"]
for stage in ("route", "stations", "ground", "planline", "profile", "cross", "footprint"):
assert stage in run["stages"], stage
assert "masshaul" in run["stages"]
assert (
sum(run["stages"][s] for s in run["stages"] if s not in ("haul", "drainage", "corridor"))
< BUDGET_MS
)
assert run["totalMs"] < BUDGET_MS
d = m["detail"]
assert d["sections"] == len(m["stations"]) == d["designs"] == d["stationIds"]
assert d["profileMatches"] and d["longitudinal"] > 10
assert m["adoptedLast"]["draft"] is True and m["redraws"] >= 1
assert m["corridor"] >= 1 and True in m["drainage"]
def test_상단측_서버_짝(r):
ts_up = [s["uphill"] for s in r["moved"]["stations"]]
py_up = [s["uphill"] for s in r["server"]]
assert len(ts_up) == len(py_up)
assert ts_up == py_up
def test_누가토량_서버_엔진과_같은_자리수(r):
m = r["moved"]
ts = m["stations"]
py = r["server"]
assert len(ts) == len(py)
nets = []
for a, b in zip(ts, py):
assert abs(a["chainage"] - b["chainage"]) < 1e-3
design = compute_cross_design(
b["samples"],
a["elevation"],
ground_type="ripping_rock",
section_mode=f"{b['uphill'] or 'left'}_cut",
plan_radius_m=b["planRadius"],
curve_outer_side=b["outer"],
curve_widening_m=b["widening"],
)
assert abs(design["cut_area_m2"] - a["cut"]) < 0.05 + 0.01 * design["cut_area_m2"]
assert abs(design["fill_area_m2"] - a["fill"]) < 0.05 + 0.01 * design["fill_area_m2"]
nets.append((b["chainage"], design["cut_area_m2"] - design["fill_area_m2"]))
final = sum((n0 + n1) / 2 * (c1 - c0) for (c0, n0), (c1, n1) in zip(nets, nets[1:]))
got = m["finalCumulative"]
assert got is not None
assert abs(got - final) <= max(1.0, abs(final) * 0.005), (got, final)
def test_늦은_응답_버림(r):
late = r["late"]
assert late["genB"] > late["genA"]
assert late["haul"]["aborted"] >= 1 # 앞 차례 흙 배분 요청 끊김
assert f"haul@{late['genA']}" not in late["haulCommits"] # 옛 차례 배분 안 들어옴
assert late["haulCommits"][-1] == f"haul@{late['genB']}"
def test_저장_관_목록이_좌표로_따라감(r):
m = r["moved"]
line = LineString(m["line"])
got = {s["key"]: s["chainage"] for s in m["stations"] if s["kind"] == "pipe"}
for pipe in r["pipes"]:
want = math.floor(line.project(Point(pipe["x"], pipe["y"])) + 0.5) # 구조물 측점 = 1 m
assert abs(got[f"pipe:{pipe['id']}"] - want) < 1e-6, (pipe["id"], got, want)
assert any(abs(got[f"pipe:{p['id']}"] - p["chainage_m"]) > 0.5 for p in r["pipes"]) # 정말 밀림
def test_초안_동안_계획고_노선_0_칸(r):
assert r["moved"]["alignmentDraft"] is not None # 노선 초안 = 노선 0 칸에 남음
g = r["gradeDraft"]
assert g["pending"] and list(g["pending"]["station_offsets"].values()) == [0.4]
assert g["sources"] == ["grade"] and "stations" not in g["stages"] and "cross" in g["stages"]
rv = r["reverted"]
assert rv["detail"] is None and rv["alignmentDraft"] is None and rv["pending"] is None
assert rv["adoptedLast"]["draft"] is False