diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 71e2cf0b6..546ab2ac5 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -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[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 { diff --git a/A00_Common/design/logic/design_calc_node_cross.ts b/A00_Common/design/logic/design_calc_node_cross.ts new file mode 100644 index 000000000..aebfa22e1 --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_cross.ts @@ -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 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 + >; + 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)); + }, + }; +} diff --git a/A00_Common/design/logic/design_calc_node_stages.ts b/A00_Common/design/logic/design_calc_node_stages.ts index 235cd3c30..c394c75d1 100644 --- a/A00_Common/design/logic/design_calc_node_stages.ts +++ b/A00_Common/design/logic/design_calc_node_stages.ts @@ -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); diff --git a/A00_Common/design/logic/design_calc_registry.ts b/A00_Common/design/logic/design_calc_registry.ts index 79b0e5d37..17e250d21 100644 --- a/A00_Common/design/logic/design_calc_registry.ts +++ b/A00_Common/design/logic/design_calc_registry.ts @@ -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", diff --git a/A00_Common/design/logic/design_calc_registry_facility.ts b/A00_Common/design/logic/design_calc_registry_facility.ts index da11ddc46..6ff6ef648 100644 --- a/A00_Common/design/logic/design_calc_registry_facility.ts +++ b/A00_Common/design/logic/design_calc_registry_facility.ts @@ -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", diff --git a/B06_Section/B06_Section_Engine_Attach.py b/B06_Section/B06_Section_Engine_Attach.py index b76e642e5..a1f6b4ce7 100644 --- a/B06_Section/B06_Section_Engine_Attach.py +++ b/B06_Section/B06_Section_Engine_Attach.py @@ -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 diff --git a/B06_Section/B06_Section_Engine_Design_Node.py b/B06_Section/B06_Section_Engine_Design_Node.py index 8cf5a24a0..f56ae204f 100644 --- a/B06_Section/B06_Section_Engine_Design_Node.py +++ b/B06_Section/B06_Section_Engine_Design_Node.py @@ -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} diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 0b8d11742..6a4fb0232 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -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) diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index 3f94f9052..00b49c437 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -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 로 diff --git a/resources/tester/test_139_6_save_redesign.py b/resources/tester/test_139_6_save_redesign.py index bf884c31f..11fa2d4b2 100644 --- a/resources/tester/test_139_6_save_redesign.py +++ b/resources/tester/test_139_6_save_redesign.py @@ -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(") diff --git a/resources/tester/test_472_3_calc_registry.py b/resources/tester/test_472_3_calc_registry.py index c1e3067ba..7c711a90e 100644 --- a/resources/tester/test_472_3_calc_registry.py +++ b/resources/tester/test_472_3_calc_registry.py @@ -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( diff --git a/resources/tester/test_511_b4_cross_save_mirror.py b/resources/tester/test_511_b4_cross_save_mirror.py new file mode 100644 index 000000000..aa77912ea --- /dev/null +++ b/resources/tester/test_511_b4_cross_save_mirror.py @@ -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))