feat(511-B B-4 [저장] 횡단 한 바퀴 · 형상 얹기 Node): [저장] · [확정] 서버 다시 셈의 횡단 한 바퀴(설계선 다시 냄 → 미교차 넓힘 → 포장 구간 → 세월교 하강 · G2 C3) = DTM 이면 늘 떠 있는 Node cross.enforce 한 부름(logic/B06_Section_Cross_Save.ts · 파이썬 차례 · 승계 · 입력 그대로 · 격자 = npz 곁 이진 · 넓힌 표본 = 횡단 파일) · DTM 아닌 지표면 · Node 못 부름 = 파이썬 길 · GET 상세 · 재생성 형상 얹기(F4 F5 F6 C6) = Node facility.attach(측점 자리만 보냄 · 구조물 정의 M02 반영 넘김 · 못 부르면 파이썬 짝) · 구조물 정본 못 읽음 = 소단 저장분 그대로 · 등록부 G2 C3 C6 F4 node: · 거울 test_511_b4_cross_save_mirror 4(가짜 지형 넓힘 · 지표면 끝 · 포장 · 기울임 · 세월교 · 소단 · 파이썬 길 = Node 길 0 차) · 검증_sub2 [저장] · [확정] 값 차 0 · 다시 셈 0.7 → 0.5 s · [확정] 2.2 → 2.1 s · GET 형상 얹기 +5.6 ms — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 14:32:16 +09:00
co-authored by Claude Opus 5.5
parent f40ff98819
commit 761f6707f9
12 changed files with 521 additions and 39 deletions
+7 -3
View File
@@ -16,6 +16,7 @@
* chainage_m? }] · facilities?: { pipes · structures }(시설 구간 · 511-B B-4) } ·
* berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } ·
* rebase? · carry?(노선 바뀐 [저장] · `design_calc_node_carry.ts`) ·
* definition? · attach? · cross_enforce?(형상 얹기 · [저장] 횡단 · `design_calc_node_cross.ts` · 511-B B-4) ·
* stages?(묶음 단계 · `design_calc_node_stages.ts` · 511-B) } }
* 출력 { results: { [id]: 값 }, available: [id…] }
* — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 ·
@@ -49,13 +50,14 @@ import {
structureAreaRows,
} from "../../../B06_Section/B06_Section_Structure_Layouts";
import { carryRunners, type CarryInputs } from "./design_calc_node_carry";
import { crossRunners, useDefinition, type CrossInputs } from "./design_calc_node_cross";
import { followRunners, type FollowInputs } from "./design_calc_node_follow";
import { STAGE_IDS, stageRunners, type StagesInputs } from "./design_calc_node_stages";
import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial";
type HaulOptions = NonNullable<Parameters<typeof computeHaulPlan>[2]>;
interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs, StagesInputs {
interface CalcInputs extends FollowInputs, CarryInputs, CrossInputs, TrialInputs, StagesInputs {
detail?: SectionDetailResponse;
route_points?: RoutePoint[];
context?: Partial<
@@ -94,7 +96,7 @@ interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs, StagesInput
chainage_m?: number;
}>;
/** 관 지점 · 구조물 정본 → 측점마다 포장 · 기울임 · 세월교 하강(`facilityRanges` · C3) */
facilities?: { pipes: CulvertPipePoint[]; structures: FacilityStructure[] };
facilities?: { pipes: CulvertPipePoint[]; structures: FacilityStructure[] | null };
};
}
@@ -110,6 +112,7 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B)
...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A)
...stageRunners(inputs), // 묶음 단계 노선 ~ 횡단(511-B B-1)
...crossRunners(inputs), // 형상 얹기 · [저장] 횡단 한 바퀴(511-B B-4)
// 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임.
"structure.layouts": () => {
const areas = structureAreaRows(sections);
@@ -167,8 +170,9 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
// 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐).
"cross.design": () => {
const batch = inputs.cross_design;
if (batch?.facilities) useDefinition(inputs.definition);
const ranges = batch?.facilities
? facilityRanges(batch.facilities.pipes, batch.facilities.structures)
? facilityRanges(batch.facilities.pipes, batch.facilities.structures ?? [])
: null;
return (batch?.stations ?? []).map((station) => {
try {
@@ -0,0 +1,72 @@
/* =============================================================================
* A00_Common/design/logic/design_calc_node_cross.ts
* 계산 Node 입구(`design_calc_node.ts`)의 횡단 몫(PLAN 511-B B-4) — 서버 GET · [저장] 이 부르는 횡단 셈을
* 브라우저와 같은 TS 로(파이썬 짝을 걷는 자리).
*
* facility.attach 측점 그림 자료(관 세트 · 벽 · 포장 · 맹암거 · 빈칸 경고 · `attachShapes` · F4 F5 F6 C6)
* 입력 attach { sections: [{ chainage_m · center_x · center_y }] · pipes · structures · types }
* 출력 { shapes: 측점마다 그림 자료 칸만 · misses: { outside · far } }
* cross.enforce [저장] 횡단 한 바퀴(`enforceSavedCross` · 설계선 → 미교차 넓힘 → 포장 → 세월교 하강 · G2 C3)
* 입력 cross_enforce = `SaveCrossInput` + grid(DTM 격자 이진 경로 · 넓힘 때만 읽음)
* 출력 측점마다 { design · samples?(넓힘) · extended? } | null
* 구조물 표 = 서버 정의(`definition` · M02 반영) — 받으면 갈아 끼움(파이썬 `catalog_row` 와 같은 표).
* ========================================================================== */
import { attachShapes, SHAPE_KEYS, type AttachType } from "@util/common_util_structure_attach";
import { useCatalogDefinition, type CatalogDefinition } from "@util/common_util_structure_catalog";
import type { PlanPipe, PlanStructure } from "@util/common_util_structure_station";
import {
enforceSavedCross,
type SaveCrossInput,
} from "../../../B06_Section/logic/B06_Section_Cross_Save";
import { gridAt } from "./design_calc_node_stages";
export interface CrossInputs {
definition?: CatalogDefinition | null;
attach?: {
sections: Array<{ chainage_m: number; center_x?: number | null; center_y?: number | null }>;
pipes: PlanPipe[];
structures: PlanStructure[];
types: AttachType[];
};
cross_enforce?: SaveCrossInput & { grid?: string | null };
}
let defined = "";
/** 서버 정의로 구조물 표를 갈아 끼움 — 같은 정의면 그대로(늘 떠 있는 Node). */
export function useDefinition(definition: CatalogDefinition | null | undefined): void {
if (!definition) return;
const key = JSON.stringify(definition);
if (key === defined) return;
useCatalogDefinition(definition);
defined = key;
}
const SHAPE_FIELDS = [...SHAPE_KEYS, "blank"];
export function crossRunners(inputs: CrossInputs): Record<string, () => unknown> {
return {
"facility.attach": () => {
useDefinition(inputs.definition);
const attach = inputs.attach;
if (!attach) throw new Error("형상 얹기 입력(attach) 없음");
const sections = attach.sections.map((section) => ({ ...section })) as Array<
Record<string, unknown>
>;
const misses = attachShapes(sections, attach.pipes, attach.structures, attach.types);
const shapes = sections.map((section) =>
Object.fromEntries(
SHAPE_FIELDS.filter((key) => key in section).map((k) => [k, section[k]]),
),
);
return { shapes, misses };
},
"cross.enforce": () => {
useDefinition(inputs.definition);
const input = inputs.cross_enforce;
if (!input) throw new Error("횡단 한 바퀴 입력(cross_enforce) 없음");
return enforceSavedCross(input, () => (input.grid ? gridAt(input.grid) : null));
},
};
}
@@ -77,7 +77,7 @@ export const STAGE_IDS = STAGE_NAMES.map((name) => `stage.${name}`);
let gridMemo: { path: string; mtime: number; grid: DtmGrid } | null = null;
/** DTM 격자 이진 — 같은 경로 · 같은 수정 시각이면 앞에 읽은 것(늘 떠 있는 Node). */
function gridAt(path: string): DtmGrid {
export function gridAt(path: string): DtmGrid {
const mtime = statSync(path).mtimeMs;
if (gridMemo?.path !== path || gridMemo.mtime !== mtime) {
const bytes = readFileSync(path);
@@ -217,10 +217,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
site: "browser_now",
stage: "cross",
ts: `${B05L}B05_Profile_Sections_Ground.ts#extendUnclosed`,
runner: `py:${B06}B06_Section_Engine_SampleExtend.py#extend_unclosed`,
until: "472-4",
runner: "node:cross.enforce",
pair: `${B06}B06_Section_Engine_SampleExtend.py#extend_unclosed`,
mirror: ["test_344_1_sections_ground_mirror"],
mirror: ["test_344_1_sections_ground_mirror", "test_511_b4_cross_save_mirror"],
note: "[저장] = Node cross.enforce 안(DTM 격자 · 511-B B-4) · DTM 아닌 지표면 = 파이썬 짝(표본기)",
},
{
id: "ground.widen",
@@ -408,11 +408,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
site: "browser_now",
stage: "cross",
ts: `${B06L}B06_Section_Cross_Enforce.ts#facilityRanges`,
runner: `py:${B06}B06_Section_Router_Design.py#enforce_design_lines`,
until: "472-4",
runner: "node:cross.enforce",
pair: `${B06}B06_Section_Router_Design.py#enforce_design_lines`,
note: "구간 판정 = 파이썬 짝(Router_Design_Ranges) · 다시 세운 설계 = Node cross.design",
mirror: ["test_349_0n_cross_enforce_mirror"],
note: "[저장] 횡단 한 바퀴 = Node cross.enforce · 기본 설계 구간 = cross.design facilities(511-B B-4) · 파이썬 짝 = DTM 아닌 지표면 · Node 못 부를 때",
mirror: ["test_349_0n_cross_enforce_mirror", "test_511_b4_cross_save_mirror"],
},
{
id: "cross.berm_spans",
@@ -451,11 +450,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
site: "browser_now",
stage: "cross",
ts: `${U}common_util_structure_underdrain.ts#attachUnderdrainSpecs`,
runner: `py:${B06}B06_Section_Engine_Tilt.py#attach_underdrains`,
until: "472-4",
runner: "node:facility.attach",
pair: `${B06}B06_Section_Engine_Tilt.py#attach_underdrains`,
mirror: ["test_472_4_underdrain_mirror"],
note: "얹기 = GET 상세(파이썬) · 알려진 차 = 옛 underdrain 걷음 TS 만 · 기울임은 cross.design 안",
note: "얹기 = GET 상세 Node facility.attach 안(511-B B-4) · 알려진 차 = 옛 underdrain 걷음 TS 만 · 기울임은 cross.design 안",
},
{
id: "cross.fit",
@@ -85,10 +85,10 @@ export const FACILITY_ROWS: readonly CalcEntry[] = [
site: "browser_now",
stage: "cross",
ts: `${U}common_util_structure_attach.ts#attachShapes`,
runner: `py:${B06}B06_Section_Engine_Attach.py#attach_shapes`,
until: "472-4",
runner: "node:facility.attach",
pair: `${B06}B06_Section_Engine_Attach.py#attach_shapes`,
mirror: ["test_323_4_attach_mirror"],
note: "GET 상세 · 재생성 = Node facility.attach(511-B B-4) · 파이썬 짝 = Node 못 부를 때",
},
{
id: "facility.culvert_sets",
+53 -6
View File
@@ -2,6 +2,8 @@
구조물 열쇠(`SHAPE_KEYS` · 빈칸 경고 `blank`)를 걷고 차례대로 다시 얹음 — 관 세트 → 벽 → 포장 → 맹암거 →
빈칸 경고. 고르기 = 표 형상(322-1). 서버 상세 읽기(`_read_section_detail`) · 재생성 응답이 이것 하나를 씀.
셈 = 늘 떠 있는 Node `facility.attach`(화면과 같은 TS · 511-B B-4) ·
파이썬 몫(`_attach_python`)은 Node 를 못 부를 때만.
TS 짝 = `common_util/common_util_structure_attach.ts` `attachShapes`(화면 = 초안 ?? 저장분 현황) ·
거울 시험 = `resources/tester/test_323_4_attach_mirror.py`.
"""
@@ -106,14 +108,10 @@ def _outside_route(pipe: Any, sections: list[dict[str, Any]]) -> bool:
return False
def attach_shapes(
def _attach_python(
sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]
) -> dict[str, list[float]]:
"""측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침) ·
시종점 밖 관 · 주인 측점 먼 관은 안 얹고 돌려줌(458 · `{"outside", "far"}`)."""
for section in sections:
for key in (*SHAPE_KEYS, "blank"):
section.pop(key, None)
"""파이썬 짝(TS `attachShapes` · Node 를 못 부를 때만)."""
misses: dict[str, list[float]] = {"outside": [], "far": []}
kept = []
for pipe in pipes:
@@ -131,6 +129,55 @@ def attach_shapes(
)
attach_underdrain_specs(sections, underdrains_from(structures))
_attach_blanks(sections, pipes, structures)
return misses
def _attach_node(
sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]
) -> dict[str, list[float]] | None:
"""늘 떠 있는 Node `facility.attach`(511-B B-4) — 측점 자리만 보내고 그림 자료 칸만 받음 ·
못 부르면 None."""
from dataclasses import asdict
from B05_Profile.B05_Profile_Structures_Schema import load_definition, load_structure_types
from common_util.common_util_calc_runner import run_calc
stubs = [
{key: section.get(key) for key in ("chainage_m", "center_x", "center_y")}
for section in sections
]
results = run_calc(
["facility.attach"],
{
"definition": load_definition(),
"attach": {
"sections": stubs,
"pipes": [asdict(pipe) for pipe in pipes],
"structures": [item.model_dump(mode="json") for item in structures],
"types": [item.model_dump(mode="json") for item in load_structure_types()],
},
},
)
if results is None:
return None
out = results["facility.attach"]
for section, shape in zip(sections, out["shapes"], strict=True):
section.update(shape)
return out["misses"]
def attach_shapes(
sections: list[dict[str, Any]], pipes: list[Any], structures: list[Any]
) -> dict[str, list[float]]:
"""측점마다 그림 자료를 걷고 현황으로 다시 얹음(TS `attachShapes` 짝 · 제자리 고침) ·
시종점 밖 관 · 주인 측점 먼 관은 안 얹고 돌려줌(458 · `{"outside", "far"}`)."""
for section in sections:
for key in (*SHAPE_KEYS, "blank"):
section.pop(key, None)
misses = _attach_node(sections, pipes, structures)
if misses is None:
logger.warning("Node 형상 얹기 실패 — 파이썬 짝으로 얹음(%d 측점)", len(sections))
misses = _attach_python(sections, pipes, structures)
if misses["outside"] or misses["far"]:
logger.warning("B06 시설 그림 안 얹음 — 노선 밖 %s · 측점 멂 %s", *misses.values())
return misses
@@ -56,10 +56,11 @@ def _python(job: DesignJob, standard: dict[str, Any] | None) -> dict[str, Any] |
Facilities = tuple[Path, list[float]]
def facility_inputs(project_root: Path) -> dict[str, list[dict[str, Any]]]:
def facility_inputs(project_root: Path) -> dict[str, list[dict[str, Any]] | None]:
"""TS `facilityRanges` 입력 — 관 = 지금 노선에 얹은 관 지점(`route_pipe_points`) · 구조물 정본.
못 읽은 쪽은 빈 목록(파이썬 `pavement_ranges` · `ford_surface_drops` 의 비치명 태도 그대로)."""
못 읽은 관 = 빈 목록 · 구조물 = None(포장 · 맹암거 구간 없음 · 소단 = 저장분 그대로 · 파이썬
`pavement_ranges` · `berm_specs_at` 의 비치명 태도 그대로)."""
from dataclasses import asdict
from B05_Profile.B05_Profile_Engine_Sections import route_pipe_points
@@ -70,11 +71,12 @@ def facility_inputs(project_root: Path) -> dict[str, list[dict[str, Any]]]:
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
logger.exception("B06 관 지점을 읽지 못했습니다 (시설 구간 없음으로 본다)")
pipes = []
structures: list[dict[str, Any]] | None
try:
structures = [s.model_dump(mode="json") for s in load_structures(str(project_root))[1]]
except Exception: # noqa: BLE001
logger.exception("B06 구조물 정본을 읽지 못했습니다 (포장 · 맹암거 구간 없음으로 본다)")
structures = []
structures = None
return {"pipes": pipes, "structures": structures}
+116 -13
View File
@@ -57,6 +57,8 @@ logger = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[1]
BUNDLE = ROOT / "config" / "server_calc_node" / "B06_Section_Server_Calc_Node.js"
# 곡선부 확폭 입력 칸(파이썬 `curve_widening_args` · TS `SaveSection`)
CURVE_KEYS = ("plan_radius_m", "curve_outer_side", "curve_widening_m")
_NPM_SCRIPT = "build:server-calc"
# 정본에 얹는 값만 받는다 — Node 가 다른 키를 내도 설계 데이터에 흘리지 않는다.
# ⚠ **TS 쪽 `STRUCTURE_ROW_KEYS` 와 짝이다.** 한쪽만 늘리면 Node 가 값을 내도 여기서 조용히
@@ -229,6 +231,105 @@ def _enforce_stored_designs(
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
def design_grid_file(project_root: Path, params: dict[str, Any] | None) -> Path | None:
"""설계 지형이 DTM 이면 격자 이진(`dtm_grid_file` · 표본기 `build_surface_sampler` 와
같은 npz) · DTM 아닌 지표면(tin · nurbs · …) · 못 엶 = None(파이썬 길)."""
from common_util.common_util_surface_sampler import dtm_grid_file
params = params or {}
if params.get("method") != "dtm" or not params.get("source_filter"):
return None
models = project_root / "B04_PreProcess" / "models"
smooth = models / f"dtm_{params['source_filter']}_smooth.npz"
npz = (
smooth
if params.get("smooth") and smooth.exists()
else smooth.with_name(f"dtm_{params['source_filter']}.npz")
)
try:
return dtm_grid_file(npz) if npz.is_file() else None
except (OSError, ValueError, KeyError) as exc:
logger.warning("서버 재계산: DTM 격자 이진을 못 만듦 — 파이썬 길 · %s", exc)
return None
def _enforce_on_python(
longitudinal: dict[str, Any],
sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None,
surface: dict[str, Any] | None,
) -> None:
"""파이썬 길(DTM 아닌 지표면 · Node 못 부름) — 설계선 → (미교차면 표본기) → 넓힘 ·
포장 · 하강."""
from B06_Section.B06_Section_Router_Design import enforce_design_lines
# 모든 측점 설계선을 저장 입력으로 먼저 다시 냄(139-6 · 노견 확장 · 측구 · 지반유형) — 미교차
# 판정도 새 설계로 해야 넓힘 지표면을 연다.
enforce_design_lines(longitudinal, sections, project_root, standard)
# 확정 지표면 — 미교차 측점이 있을 때만 연다(샘플 넓힘 ㉳ (가) · 없으면 여는 비용을 안 씀).
unclosed = any((item.get("design") or {}).get("slope_unclosed") for item in sections)
sampler = _surface_sampler(project_root, surface) if unclosed else None
_enforce_stored_designs(longitudinal, sections, project_root, standard, sampler)
def _enforce_on_node(
longitudinal: dict[str, Any],
sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None,
grid: Path,
) -> bool:
"""Node `cross.enforce` 한 부름(TS `B06_Section_Cross_Save.enforceSavedCross`) — 측점 설계 ·
넓힌 표본을 자리에서 갈고 넓힌 표본은 횡단 파일에 남김. 못 부르면 False(파이썬 길)."""
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B05_Profile.B05_Profile_Structures_Schema import load_definition
from B06_Section.B06_Section_Engine_Design_Node import facility_inputs, full_standard
from common_util.common_util_calc_runner import run_calc
from common_util.common_util_json import atomic_write_json
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
keys = ("chainage_m", "design", "samples", "frame", *CURVE_KEYS)
profiles = longitudinal.get("design_profiles") or []
results = run_calc(
["cross.enforce"],
{
"definition": load_definition(),
"cross_enforce": {
"profile": (profiles[0] or {}).get("samples") or [] if profiles else [],
"sections": [{k: s.get(k) for k in keys if k in s} for s in sections],
"standard": full_standard(standard),
"rockDefaultM": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
**facility_inputs(project_root),
"grid": str(grid),
},
},
)
if results is None:
logger.warning("서버 재계산: Node 횡단 한 바퀴 실패 — 파이썬 길")
return False
cross_dir = project_root / "B06_Section" / "cross_sections"
for section, row in zip(sections, results["cross.enforce"], strict=True):
if not row:
continue
section["design"] = row["design"]
if row.get("samples"):
section["samples"] = row["samples"]
cross_path = find_cross_file(cross_dir, float(section.get("chainage_m") or 0.0))
if cross_path.is_file():
stored = json.loads(cross_path.read_text(encoding="utf-8"))
atomic_write_json(cross_path, {**stored, "samples": row["samples"]})
info = row.get("extended") or {}
logger.info(
"미교차 샘플 넓힘: 측점 %.3f 좌 +%sm 우 +%sm 지표면 끝 %s",
float(section.get("chainage_m") or 0.0),
info.get("left_m"),
info.get("right_m"),
info.get("surface_end"),
)
return True
def refresh_alignment(longitudinal: dict[str, Any]) -> dict[str, Any] | None:
"""종단 경고를 저장 입력(base_pvi · 편집 · 정책 · 기준)으로 지금 코드가 다시 셈 — 옛 코드가
쓴 경고가 남지 않게(472 덧 · 요구 272). 기하(계획고 · 곡선 · 표본 · 위반)는 저장값 그대로 —
@@ -324,24 +425,26 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
standard = stored_standard_cross_section(longitudinal_row)
# 포장 구간·세월교 보정 — 고쳐진 설계 위에서 면적·유토곡선이 나와야 한다.
before = [json.dumps(item.get("design"), sort_keys=True, default=str) for item in sections]
# 모든 측점 설계선을 저장 입력으로 먼저 다시 냄(139-6 · 노견 확장 · 측구 · 지반유형) — 미교차
# 판정도 새 설계로 해야 넓힘 지표면을 연다.
from B06_Section.B06_Section_Router_Design import enforce_design_lines
await asyncio.to_thread(
enforce_design_lines, detail.get("longitudinal") or {}, sections, project_root, standard
)
# 확정 지표면 — 미교차 측점이 있을 때만 연다(샘플 넓힘 ㉳ (가) · 없으면 여는 비용을 안 씀).
unclosed = any((item.get("design") or {}).get("slope_unclosed") for item in sections)
sampler = await asyncio.to_thread(_surface_sampler, project_root, surface) if unclosed else None
await asyncio.to_thread(
_enforce_stored_designs,
# 횡단 한 바퀴(설계선 → 미교차 넓힘 → 포장 → 세월교 하강) — DTM 이면 Node 한 부름
# (화면과 같은 TS · 511-B B-4) · DTM 아닌 지표면 · Node 못 부름 = 파이썬 길
grid = await asyncio.to_thread(design_grid_file, project_root, surface)
on_node = grid is not None and await asyncio.to_thread(
_enforce_on_node,
detail.get("longitudinal") or {},
sections,
project_root,
standard,
sampler,
grid,
)
if not on_node:
await asyncio.to_thread(
_enforce_on_python,
detail.get("longitudinal") or {},
sections,
project_root,
standard,
surface,
)
fixed = [
item
for index, item in enumerate(sections)
+2
View File
@@ -44,6 +44,8 @@ NODE_IDS = frozenset(
"cross.berm_spans",
"facility.rebase",
"stations.carry",
"facility.attach",
"cross.enforce",
}
)
# 시험 길(`design_calc_node_trial.ts` `TRIAL` 표와 같음) — 조작 중 브라우저 함수를 서버가 같은 TS 로
+2 -1
View File
@@ -69,4 +69,5 @@ def test_서버_재계산이_먼저_돌고_미교차_판정은_그_뒤() -> None
src = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text(
encoding="utf-8"
)
assert src.index("enforce_design_lines, detail.get") < src.index("unclosed = any(")
# 파이썬 길(DTM 아닌 지표면) — 설계선 먼저 · Node 길 차례 = `test_511_b4_cross_save_mirror`
assert src.index("enforce_design_lines(longitudinal, sections") < src.index("unclosed = any(")
@@ -48,6 +48,8 @@ NODE_IDS = {
"cross.berm_spans",
"facility.rebase",
"stations.carry",
"facility.attach", # 511-B B-4 — GET 형상 얹기
"cross.enforce", # 511-B B-4 — [저장] 횡단 한 바퀴
}
pytestmark = pytest.mark.skipif(
@@ -0,0 +1,251 @@
"""PLAN 511-B B-4 (요구 320-4) — [저장] 횡단 한 바퀴 파이썬 길 = Node 길(`cross.enforce`) · 형상 얹기 Node.
못박는 것
- 가짜 지형(DTM · 빈칸 = 지표면 끝) · 관 지점(세월교 · 물넘이포장 둘 · 배관) · 구조물(포장 · 맹암거 · 소단 ·
사토장)로 파이썬 길(`_enforce_on_python` = 설계선 → 표본기 넓힘 → 포장 → 하강)과 Node 길
(`_enforce_on_node` · TS `B06_Section_Cross_Save.enforceSavedCross`)의 측점 설계 · 넓힌 표본 0 차.
- 그 사이 넓힘 · 지표면 끝 · 포장 켬 · 끔 · 기울임 · 세월교 하강 · 소단 · 사용자 값 승계가 실제로 일어남.
- 구조물 정본을 못 읽으면 두 길 다 소단 = 저장분 그대로 · 포장 · 맹암거 구간 없음.
- DTM 아닌 지표면(tin) = 격자 없음(파이썬 길) · 형상 얹기 = Node(`facility.attach`) 부름.
./venv/Scripts/python.exe -m pytest resources/tester/test_511_b4_cross_save_mirror.py -q
"""
from __future__ import annotations
import copy
import json
import math
import sys
from pathlib import Path
import numpy as np
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
for path in (ROOT, HERE):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
from test_349_0n_cross_enforce_mirror import PIPES # noqa: E402
import B05_Profile.B05_Profile_Engine_Sections as sections_mod # noqa: E402
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_Ranges as ranges_mod # noqa: E402
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance # noqa: E402
from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild # 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_surface_sampler import DtmGridSampler # noqa: E402
from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치"
)
SURFACE = {"method": "dtm", "source_filter": "classification", "smooth": False}
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="bm1",
type_id="berm",
placement="interval",
start_m=180,
end_m=230,
options={"width_m": 1.0, "interval_m": 3.0, "slope_deg": 0.0, "depth_m": 1.0},
),
StructureInstance(
structure_id="sp1",
type_id="spoil_bank",
placement="site",
chainage_m=335,
options={"capacity_m3": 300, "side": "자동(성토 쪽)"},
),
]
OFFSETS = [round(-20 + 0.5 * i, 1) for i in range(81)]
def _plan(chainage: float) -> float:
return round(100.5 + 0.6 * math.sin(chainage / 53) + 0.002 * chainage, 4)
def _npz(root: Path) -> None:
"""DTM 격자 — y = 노선 왼쪽(+) · −42 m 아래 빈칸(지표면 끝) · x 180~240 은 −30 m 아래도."""
models = root / "B04_PreProcess" / "models"
models.mkdir(parents=True)
x = np.arange(-10.0, 431.0, 1.0)
y = np.arange(-80.0, 81.0, 1.0)
xx, yy = np.meshgrid(x, y)
# 가로 기울기 0.68 ~ 0.78(반폭 20 m 에서 안 닫히는 측점 · 닫히는 거리 갈림 · 지표면 끝)
z = 100 + (0.73 + 0.05 * np.sin(xx / 40)) * yy + 0.8 * np.sin(xx / 37) + 0.3 * np.cos(yy / 3)
valid = ~((yy < -42) | ((xx >= 180) & (xx <= 240) & (yy < -30)))
np.savez(models / f"dtm_{SURFACE['source_filter']}.npz", x=x, y=y, z=z, valid_mask=valid)
def _sections(root: Path) -> tuple[dict, list[dict]]:
sampler = DtmGridSampler.from_npz(
root / "B04_PreProcess" / "models" / f"dtm_{SURFACE['source_filter']}.npz"
)
chainages = sorted({20.0 * i for i in range(21)} | {p["chainage_m"] for p in PIPES})
longitudinal = {
"design_profiles": [
{"samples": [{"chainage_m": c, "elevation_m": _plan(c)} for c in chainages]}
]
}
out = []
for index, c in enumerate(chainages):
xy = np.array([[c, o] for o in OFFSETS])
z, _valid = sampler.sample_xy(xy)
samples = [
{
"offset_m": o,
"x": c,
"y": o,
"z": round(float(e), 6),
"elevation_m": round(float(e), 6),
"valid": True,
}
for o, e in zip(OFFSETS, z)
]
# 저장분 = 옛 입력(포장 · 기울임 · 하강은 지금 시설과 다르게) + 사용자 값
options = dict(
ground_type="soil" if index % 3 == 0 else "ripping_rock",
section_mode="left_cut" if index % 2 else "right_cut",
ditch_type="standard",
paved=index % 4 == 0,
standard=STANDARD_CROSS_SECTION,
surface_drop_m=0.0,
shoulder_extend_left_m=0.3 if index % 5 == 0 else 0.0,
)
design = compute_cross_design(samples, _plan(c), **options)
design.update(status="provisional", pavement_suggested=False, structure_note="메모")
if index % 5 == 0:
design["shoulder_extend_left_m"] = 0.3
if index % 7 == 0:
design["cut_slope_ratio_user"] = 0.8
if index % 6 == 0:
design["berm"] = {"width_m": 1.5, "interval_m": 5.0, "slope_deg": 0.0, "depth_m": 2.0}
design["berm_depth_left_m"] = 0.5
out.append(
{
"chainage_m": c,
"center_x": c,
"center_y": 0.0,
"frame": {"origin": {"x": c, "y": 0.0}, "left_xy": [0.0, 1.0]},
"plan_radius_m": 60.0 if index == 4 else None,
"curve_outer_side": "left" if index == 4 else None,
"curve_widening_m": 0.5 if index == 4 else None,
"samples": samples,
"design": design,
}
)
return longitudinal, out
def _facilities(monkeypatch, readable: bool = True) -> None:
pipes = parse_pipe_points(PIPES)
def structures(_root):
if not readable:
raise OSError("구조물 정본 못 읽음(시험)")
return 0, STRUCTURES
monkeypatch.setattr(sections_mod, "route_pipe_points", lambda *_a, **_k: pipes)
for module in (structures_repo, ranges_mod, tilt_mod):
monkeypatch.setattr(module, "load_structures", structures)
def _both(tmp_path: Path, monkeypatch, readable: bool = True):
root = tmp_path / "p"
_npz(root)
_facilities(monkeypatch, readable)
longitudinal, sections = _sections(root)
py, node = copy.deepcopy(sections), copy.deepcopy(sections)
prebuild._enforce_on_python(longitudinal, py, root, None, SURFACE)
grid = prebuild.design_grid_file(root, SURFACE)
assert grid is not None and grid.is_file()
assert prebuild._enforce_on_node(longitudinal, node, root, None, grid)
return sections, py, node
def _same(py: list[dict], node: list[dict]) -> None:
"""값 0 차(수는 값으로 — Node 를 거친 저장분 칸은 5.0 이 5 로 옴)."""
for left, right in zip(py, node, strict=True):
assert left["samples"] == right["samples"], left["chainage_m"]
assert left["design"] == right["design"], left["chainage_m"]
def test_저장_횡단_한_바퀴_파이썬_길_Node_길_0차(tmp_path, monkeypatch):
before, py, node = _both(tmp_path, monkeypatch)
_same(py, node)
by = {s["chainage_m"]: s for s in node}
widened = [s["chainage_m"] for s in node if len(s["samples"]) > len(OFFSETS)]
assert len(widened) >= 5, widened # 넓힘이 실제로 돎
assert any(s["design"].get("slope_unclosed") for s in node) # 지표면 끝까지 넓혀도 안 닫힘
assert (
by[60.0]["design"]["paved"] is True and by[100.0]["design"]["paved"] is False
) # 포장 켬 · 끔
assert by[160.0]["design"]["tilted"] is True # 맹암거 자리 기울임
assert by[250.0]["design"]["paved"] is True and by[250.0]["design"]["tilted"] is True # 물넘이
assert by[110.0]["design"]["surface_drop_m"] > 0 # 세월교 하강
assert by[200.0]["design"]["berm"]["width_m"] == 1.0 # 소단 = 구조물 정본 구간
assert (
"berm" not in by[120.0]["design"] or by[120.0]["design"]["berm"] is None
) # 구간 밖 = 없음
assert all(s["design"]["structure_note"] == "메모" for s in node) # 사용자 값 승계
assert by[0.0]["design"]["shoulder_extend_left_m"] == 0.3
assert any(a["design"] != b["design"] for a, b in zip(before, node))
def test_구조물_정본_못_읽음_소단_저장분_그대로(tmp_path, monkeypatch):
before, py, node = _both(tmp_path, monkeypatch, readable=False)
_same(py, node)
by = {s["chainage_m"]: s for s in node}
saved = {s["chainage_m"]: s for s in before}
assert by[0.0]["design"]["berm"] == saved[0.0]["design"]["berm"] # 저장분 소단
assert by[60.0]["design"]["paved"] is False # 포장 구간 없음(물넘이 밖)
assert not by[160.0]["design"].get("tilted") # 맹암거 구간 없음(칸 없음)
def test_DTM_아닌_지표면은_격자_없음(tmp_path):
root = tmp_path / "p"
_npz(root)
assert prebuild.design_grid_file(root, {**SURFACE, "method": "tin"}) is None
assert prebuild.design_grid_file(root, {**SURFACE, "source_filter": "없음"}) is None
def test_형상_얹기는_Node(monkeypatch):
from B06_Section import B06_Section_Engine_Attach as attach_mod
from common_util import common_util_calc_runner as runner
calls = []
real = runner.run_calc
monkeypatch.setattr(
runner, "run_calc", lambda ids, inputs: calls.append(ids) or real(ids, inputs)
)
sections = [
{"chainage_m": float(c), "center_x": float(c), "center_y": 0.0} for c in range(0, 401, 10)
]
misses = attach_mod.attach_shapes(sections, parse_pipe_points(PIPES), STRUCTURES)
assert calls == [["facility.attach"]]
assert misses == {"outside": [], "far": []}
keys = {k for s in sections for k in s} - {"chainage_m", "center_x", "center_y"}
assert {"ford", "ford_pavement", "culvert", "pavement", "underdrain"} <= keys
# 파이썬 짝(Node 못 부를 때)과 같음
py = [
{"chainage_m": s["chainage_m"], "center_x": s["center_x"], "center_y": 0.0}
for s in sections
]
attach_mod._attach_python(py, parse_pipe_points(PIPES), STRUCTURES)
assert json.loads(json.dumps(py)) == json.loads(json.dumps(sections))