Merge remote-tracking branch 'origin/dev' into sub_laptop_4

This commit is contained in:
2026-10-19 09:41:03 +09:00
26 changed files with 715 additions and 179 deletions
+12 -1
View File
@@ -11,7 +11,8 @@
* excavation?: { pipes: [{ diameter_mm }] · walls: [{ height_m · average_thickness_m ·
* has_foundation }] } ·
* cross_design?: { standard: 표준 횡단(config 위에 저장분 덮은 한 벌) ·
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } } }
* stations: [{ samples · design_elevation_m · options: 파이썬 `compute_cross_design` 키 }] } ·
* berm?: { structures: 구조물 정본 목록 · chainages: [측점 m] } } }
* 출력 { results: { [id]: 값 }, available: [id…] }
* — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 ·
* server-calc 묶음과 같은 차례).
@@ -19,6 +20,7 @@
* ========================================================================== */
import { readFileSync, writeFileSync } from "node:fs";
import { bermSpansFromStructures, bermSpecAt } from "@util/common_util_berm_spans";
import type { BermSpec } from "@util/common_util_cross_berm";
import { computeCrossDesign } from "@util/common_util_cross_design";
import type { CrossDesignOptions, CrossGroundSample } from "@util/common_util_cross_design";
@@ -63,6 +65,10 @@ interface CalcInputs {
has_foundation?: boolean;
}>;
};
berm?: {
structures: Parameters<typeof bermSpansFromStructures>[0];
chainages: number[];
};
cross_design?: {
standard: CrossDesignOptions["standard"];
stations?: Array<{
@@ -179,6 +185,11 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
),
})),
}),
// 소단 구간(C4) — 구조물 정본 → 구간 · 측점마다 제원(겹치면 먼저 놓은 것).
"cross.berm_spans": () => {
const spans = bermSpansFromStructures(inputs.berm?.structures ?? []);
return { spans, at: (inputs.berm?.chainages ?? []).map((c) => bermSpecAt(spans, c)) };
},
// 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐).
"cross.design": () => {
const batch = inputs.cross_design;
+12 -14
View File
@@ -365,11 +365,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
site: "browser_now",
stage: "cross",
ts: `${U}common_util_cross_design.ts#computeCrossDesign`,
runner: `py:${B06}B06_Section_Engine_Design.py#compute_cross_design`,
until: "472-4",
runner: "node:cross.design",
pair: `${B06}B06_Section_Engine_Design.py#compute_cross_design`,
mirror: ["test_b06_cross_design_mirror", "test_178_2_berm_fill_mirror"],
note: "파이썬 짝은 B07 · 추천 · Route_Save · B05 확정도 부름 — 짝 남음",
note: "[저장] · [확정] 다시 셈 = Node(472-4 ②) · 파이썬 짝 남음(B07 · 추천 · Route_Save · B05 확정 · 넓힘 찾기) · 알려진 차 = 설계선 점 반올림 동점 1e-4 m",
},
{
id: "cross.areas",
@@ -380,11 +379,10 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
site: "browser_now",
stage: "cross",
ts: `${U}common_util_cross_design_areas.ts#splitCutAreas`,
runner: `py:${B06}B06_Section_Engine_Areas.py#_split_cut_areas`,
until: "472-4",
runner: "node:cross.design",
pair: `${B06}B06_Section_Engine_Areas.py#_split_cut_areas`,
mirror: ["test_b06_cross_design_mirror"],
note: "X3 — 표준 측점 = 파이썬 · 구조물 측점 = Node(structure.layouts)",
note: "설계선과 한 셈(cross.design) · X3 한 갈래 — 표준 측점 · 구조물 측점(structure.layouts) 모두 Node",
},
{
id: "cross.enforce",
@@ -398,6 +396,7 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
runner: `py:${B06}B06_Section_Router_Design.py#enforce_design_lines`,
until: "472-4",
pair: `${B06}B06_Section_Router_Design.py#enforce_design_lines`,
note: "구간 판정 = 파이썬 짝(Router_Design_Ranges) · 다시 세운 설계 = Node cross.design",
mirror: ["test_349_0n_cross_enforce_mirror"],
},
{
@@ -408,12 +407,11 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
outputs: ["소단 구간"],
site: "browser_now",
stage: null,
ts: `${B06}B06_Section_UI_Page_Persist.ts#bermSpansFromStructures`,
runner: `py:${B06}B06_Section_Engine_Structures_Wall.py#load_berm_spans`,
until: "472-4",
ts: `${U}common_util_berm_spans.ts#bermSpansFromStructures`,
runner: "node:cross.berm_spans",
pair: `${B06}B06_Section_Engine_Structures_Wall.py#load_berm_spans`,
mirror: ["test_272_berm_source"],
note: "TS 가 화면 파일 안 — common_util 로(472-4 ②)",
note: "[저장] 다시 셈 = Node(472-4 ②) · 파이썬 짝은 Node 못 부를 때만",
},
{
id: "cross.berm_slope",
@@ -424,9 +422,9 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
site: "browser_now",
stage: "cross",
ts: `${U}common_util_cross_berm.ts#cutProfilePoints`,
runner: `py:${U}common_util_cross_berm.py#cut_profile_points`,
until: "472-4",
runner: "node:cross.design",
pair: `${U}common_util_cross_berm.py#cut_profile_points`,
note: "설계선 셈 안(cross.design) · 파이썬 짝 남음(cross.design 과 같은 까닭)",
mirror: ["test_178_2_berm_fill_mirror"],
},
{
@@ -441,8 +439,8 @@ export const CALC_REGISTRY: readonly CalcEntry[] = [
runner: `py:${B06}B06_Section_Engine_Tilt.py#attach_underdrains`,
until: "472-4",
pair: `${B06}B06_Section_Engine_Tilt.py#attach_underdrains`,
mirror: [],
note: "거울 시험 없음 — 옮기기 전 먼저",
mirror: ["test_472_4_underdrain_mirror"],
note: "얹기 = GET 상세(파이썬) · 알려진 차 = 옛 underdrain 걷음 TS 만 · 기울임은 cross.design 안",
},
{
id: "cross.fit",
+47
View File
@@ -15,6 +15,7 @@ import aiomysql
import numpy as np
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions
from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
AlignmentPolicy,
build_curves,
@@ -23,6 +24,7 @@ from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
)
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
from B05_Profile.B05_Profile_Engine_Sections import (
_annotate_pavement_suggestions,
_load_pipe_points,
_load_route_polyline,
find_cross_file,
@@ -421,6 +423,47 @@ def _follow_moved_stations(
return {"id": profile["id"], **profile["summary"]}
#: 구조물 측점을 새로 떠 갈음할 때 같은 자리 옛 줄에서 들고 가는 칸(472 덧) — 상단측 출처
#: (`user` = 손 지정 · 값도 옛 줄) · 포장 제안 표기(`_annotate_pavement_suggestions` · 새 줄 없음).
STATION_CARRY_KEYS = (
"uphill_side_source",
"pavement_suggested",
"pavement_grade_pct",
"pavement_grade_limit_pct",
)
def _carry_station_fields(
old: dict[float, dict[str, Any]], fresh: dict[float, dict[str, Any]]
) -> list[dict[str, Any]]:
"""같은 자리 옛 줄의 `STATION_CARRY_KEYS` 를 새 줄에 얹음 · 사용자 상단측은 새 자동값을 이김.
반환 = 옛 줄 없는 새 자리(포장 표기를 지금 계획선으로 셀 줄)."""
born = []
for key, station in fresh.items():
before = old.get(key)
if before is None:
born.append(station)
continue
for name in STATION_CARRY_KEYS:
if name in before and name not in station:
station[name] = before[name]
if before.get("uphill_side_source") == "user" and before.get("uphill_side"):
station["uphill_side"] = before["uphill_side"]
return born
def _pavement_for(data: dict[str, Any], stations: list[dict[str, Any]]) -> None:
"""새 자리 측점의 포장 제안 표기 — 종단 생성과 같은 셈 · 기준 = 계획선 `criteria`."""
profiles = data.get("design_profiles") or []
criteria = (profiles[0].get("criteria") if profiles else None) or {}
names = GradeDesignOptions.__dataclass_fields__.keys() - {"warnings"}
try:
options = GradeDesignOptions(**{k: v for k, v in criteria.items() if k in names})
except TypeError:
return # 옛 종단 — 기준 칸이 모자람
_annotate_pavement_suggestions({"design_profiles": profiles, "stations": stations}, options)
def _merge_irregular_into_longitudinal(
project_root: Path, longitudinal_file_path: str, irregular_stations: list[dict[str, Any]]
) -> dict[str, Any] | None:
@@ -451,6 +494,9 @@ def _merge_irregular_into_longitudinal(
if station.get("kind") in ("irregular", "curve")
]
fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations}
born = _carry_station_fields(
{round(float(s.get("chainage_m", 0.0)), 6): s for s in stations}, fresh
)
kept: dict[float, dict[str, Any]] = {}
left: list[dict[str, Any]] = []
for station in stations:
@@ -470,6 +516,7 @@ def _merge_irregular_into_longitudinal(
data["stations"] = sorted(kept.values(), key=lambda s: float(s.get("chainage_m", 0.0)))
previous = [round(float(station.get("chainage_m", 0.0)), 6) for station in stations]
summary = _follow_moved_stations(data, before, previous)
_pavement_for(data, born) # 계획선을 옮긴 뒤 — 새 자리 경사는 지금 계획선으로
atomic_write_json(path, data)
_relabel_cross_files(path.parent.parent / "cross_sections", left)
return summary
@@ -0,0 +1,104 @@
"""횡단 설계선 · 면적 · 소단 구간을 서버 Node 입구로(PLAN 472-4 ② · 등록부 `cross.design` ·
`cross.berm_spans`) — 브라우저와 같은 TS(`common_util_cross_design` · `common_util_berm_spans`).
파이썬은 입력 조립만 — 측점마다 `compute_cross_design` 키를 모아 Node 한 번에 보냄.
Node 를 못 부르면 파이썬 짝으로(거울 시험 `test_b06_cross_design_mirror` · `test_272_berm_source` ·
알려진 차 = 설계선 점 반올림 동점 1e-4 m — 파이썬 round 짝수 쪽 · TS 위쪽).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from B06_Section.B06_Section_Engine_Design import _resolve_group, compute_cross_design
from common_util.common_util_calc_runner import run_calc
logger = logging.getLogger(__name__)
# 한 측점 입력 = (지반 표본, 계획고, `compute_cross_design` 키 — standard 뺌)
DesignJob = tuple[list[dict[str, Any]], float | None, dict[str, Any]]
def full_standard(standard: dict[str, Any] | None) -> dict[str, Any]:
"""config 기본값 위에 저장분을 덮은 표준 횡단 한 벌 — TS 는 config 를 모름(입력으로 받음)."""
out: dict[str, Any] = {}
for key in ("soil", "rock", "paved"):
g = _resolve_group(key, standard)
out[key] = {
"road_width_m": g["road_width_m"],
"shoulder_left_m": g["shoulder_left_m"],
"shoulder_right_m": g["shoulder_right_m"],
"ditch": {
"top_width_m": g["ditch_top_width_m"],
"bottom_width_m": g["ditch_bottom_width_m"],
"depth_m": g["ditch_depth_m"],
},
"ditch_l_type": {"width_m": g["l_ditch_width_m"], "depth_m": g["l_ditch_depth_m"]},
"cross_slope_pct": {"min": g["cross_slope_pct"]},
"fill_slope_ratio": g["fill_slope_ratio"],
"cut_slope_ratio": g["cut_slope_ratio"],
"pavement_thickness_m": g["pavement_thickness_m"],
}
return out
def _python(job: DesignJob, standard: dict[str, Any] | None) -> dict[str, Any] | None:
samples, elevation, options = job
try:
return compute_cross_design(samples, elevation, standard=standard, **options)
except (ValueError, KeyError):
return None
def compute_cross_designs(
jobs: list[DesignJob], standard: dict[str, Any] | None
) -> list[dict[str, Any] | None]:
"""측점 묶음 횡단 설계 — 차례 그대로 결과 · None = 못 세움(파이썬 ValueError 자리)."""
if not jobs:
return []
stations = [
{
"samples": samples,
"design_elevation_m": elevation,
"options": {
key: (value._asdict() if key == "berm" and value is not None else value)
for key, value in options.items()
},
}
for samples, elevation, options in jobs
]
results = run_calc(
["cross.design"],
{"cross_design": {"standard": full_standard(standard), "stations": stations}},
)
if results is None:
logger.warning("Node 횡단 설계 실패 — 파이썬 짝으로 셈(%d 측점)", len(jobs))
return [_python(job, standard) for job in jobs]
return [None if "error" in row else row for row in results["cross.design"]]
def berm_specs_at(
project_root: Path, chainages: list[float]
) -> list[dict[str, float] | None] | None:
"""측점마다 소단 제원(구조물 정본 구간) · None = 정본을 못 읽음(부르는 쪽이 저장분 그대로)."""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans
try:
structures = [
item.model_dump(include={"type_id", "start_m", "end_m", "chainage_m", "options"})
for item in load_structures(str(project_root))[1]
]
except Exception: # noqa: BLE001 — 정본을 못 읽으면 저장분으로 물러섬
logger.exception("B06 소단 구간을 읽지 못했습니다 (저장분 소단을 씀)")
return None
results = run_calc(
["cross.berm_spans"], {"berm": {"structures": structures, "chainages": chainages}}
)
if results is not None:
return results["cross.berm_spans"]["at"]
logger.warning("Node 소단 구간 실패 — 파이썬 짝으로 셈")
spans = load_berm_spans(project_root)
return None if spans is None else [berm_span_at(spans, c) for c in chainages]
+19 -7
View File
@@ -118,6 +118,7 @@ def extend_unclosed_sections(
sampler: Any,
) -> int:
"""저장 설계가 미교차인 측점을 넓혀 샘플·설계를 자리에서 갈고 횡단 파일에 남긴다. 넓힌 수."""
from B06_Section.B06_Section_Engine_Design_Node import compute_cross_designs
from B06_Section.B06_Section_Router_Design import (
USER_TOUCHED_KEYS,
ford_drop_at,
@@ -132,23 +133,21 @@ def extend_unclosed_sections(
return 0
drops = ford_surface_drops(project_root)
cross_dir = project_root / "B06_Section" / "cross_sections"
changed = 0
done: list[tuple[dict[str, Any], dict[str, Any], float, list[dict[str, Any]], dict, tuple]] = []
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict) or not design.get("slope_unclosed"):
continue
chainage = float(section.get("chainage_m", 0.0))
def compute(samples: list[dict[str, Any]], design=design, chainage=chainage) -> dict:
return compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage),
try:
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
options = dict(
ground_type=str(design.get("ground_type") or "ripping_rock"),
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=bool(design.get("paved", False)),
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
@@ -162,6 +161,12 @@ def extend_unclosed_sections(
**stored_shoulder_extend(design), # 노견 확장(139-6)
**stored_tilt(design), # 물넘이 · 맹암거 자리 측구 없음 · 기울기(286-2 · 287-4)
)
except (ValueError, KeyError):
continue
# 넓힘 찾기 = 파이썬 짝(되풀이 셈) · 넓힌 뒤 정본 설계 = Node 한 번(472-4 ② · 아래)
def compute(samples: list[dict[str, Any]], z=elevation, opts=options) -> dict:
return compute_cross_design(samples, z, standard=standard, **opts)
try:
result = extend_unclosed(
@@ -174,7 +179,14 @@ def extend_unclosed_sections(
samples, info = result
if not info["left_m"] and not info["right_m"]:
continue # 첫 걸음부터 지표면 밖 — 샘플이 그대로라 갈 것이 없음
recomputed = compute(samples)
done.append((section, design, chainage, samples, info, (samples, elevation, options)))
results = compute_cross_designs([job for *_rest, job in done], standard)
changed = 0
for (section, design, chainage, samples, info, _job), recomputed in zip(
done, results, strict=True
):
if recomputed is None:
continue
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if design.get(key) is not None:
recomputed[key] = design[key]
+47 -43
View File
@@ -15,6 +15,11 @@ from B06_Section.B06_Section_Engine_Design import (
stored_shoulder_extend,
stored_tilt,
)
from B06_Section.B06_Section_Engine_Design_Node import (
DesignJob,
berm_specs_at,
compute_cross_designs,
)
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
# 포장 · 세월교 구간(나눈 파일) — 이 모듈 이름으로 다시 내보냄(부르는 곳 · 시험 갈아 끼움 그대로).
@@ -130,6 +135,26 @@ def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> di
return standard if isinstance(standard, dict) else None
def apply_designs(
jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]], standard: dict[str, Any] | None
) -> int:
"""모은 측점을 Node 한 번(TS 짝 · 472-4 ②)으로 다시 세워 자리에서 갈아 끼움 · 바꾼 수.
`design` = 승계 원천(사용자 조작값 · 상태) · 못 세운 측점은 저장분 그대로(옛 ValueError 자리).
"""
results = compute_cross_designs([job for _section, _design, job in jobs], standard)
changed = 0
for (section, design, _job), recomputed in zip(jobs, results, strict=True):
if recomputed is None:
continue
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if design.get(key) is not None:
recomputed[key] = design[key]
section["design"] = recomputed
changed += 1
return changed
def enforce_pavement_ranges(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
@@ -146,7 +171,7 @@ def enforce_pavement_ranges(
ranges = pavement_ranges(project_root)
tilts = tilt_ranges(project_root)
ford_drops = ford_surface_drops(project_root)
changed = 0
jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]] = []
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict):
@@ -157,15 +182,12 @@ def enforce_pavement_ranges(
if bool(design.get("paved")) == wanted and stored_tilt(design) == tilt:
continue
try:
recomputed = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
options = dict(
ground_type=str(design.get("ground_type") or "ripping_rock"),
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=wanted,
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
@@ -179,14 +201,11 @@ def enforce_pavement_ranges(
**stored_shoulder_extend(design), # 노견 확장(139-6)
**tilt,
)
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
except (ValueError, KeyError):
continue
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if design.get(key) is not None:
recomputed[key] = design[key]
section["design"] = recomputed
changed += 1
return changed
jobs.append((section, design, (section.get("samples", []), elevation, options)))
return apply_designs(jobs, standard)
def enforce_design_lines(
@@ -203,29 +222,23 @@ def enforce_design_lines(
확장) + 지금 종단 계획고 · 세월교 월류 하강 · 곡선부 확폭. 사용자 조작값은 승계.
소단 제원은 구조물 정본(`structures.json`)의 구간이 원천 — 쪽마다 옮긴 깊이는 측점 patch(194-1).
"""
from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans
ford_drops = ford_surface_drops(project_root)
tilts = tilt_ranges(project_root)
spans = load_berm_spans(project_root)
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict):
continue
chainage = float(section.get("chainage_m", 0.0))
if spans is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로
design = {**design, "berm": berm_span_at(spans, chainage)}
targets = [section for section in cross_sections if isinstance(section.get("design"), dict)]
chainages = [float(section.get("chainage_m", 0.0)) for section in targets]
berms = berm_specs_at(project_root, chainages) # Node 소단 구간(C4) · None = 못 읽음
jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]] = []
for index, (section, chainage) in enumerate(zip(targets, chainages, strict=True)):
design = section["design"]
if berms is not None: # 구간을 지웠으면 소단 없음 · 못 읽으면 저장분 그대로
design = {**design, "berm": berms[index]}
try:
recomputed = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
options = dict(
ground_type=str(design.get("ground_type") or "ripping_rock"),
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=bool(design.get("paved", False)),
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
@@ -239,14 +252,11 @@ def enforce_design_lines(
**stored_shoulder_extend(design),
**tilt_args(chainage, tilts),
)
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
except (ValueError, KeyError):
continue
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if design.get(key) is not None:
recomputed[key] = design[key]
section["design"] = recomputed
changed += 1
return changed
jobs.append((section, design, (section.get("samples", []), elevation, options)))
return apply_designs(jobs, standard)
def enforce_ford_surface_drops(
@@ -262,7 +272,7 @@ def enforce_ford_surface_drops(
월류 높이를 지우면 같은 경로로 원래 계획고로 되돌아온다. 사용자 조작값은 승계한다.
"""
drops = ford_surface_drops(project_root)
changed = 0
jobs: list[tuple[dict[str, Any], dict[str, Any], DesignJob]] = []
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict):
@@ -273,15 +283,12 @@ def enforce_ford_surface_drops(
if abs(wanted - stored) < 1e-6:
continue
try:
recomputed = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
options = dict(
ground_type=str(design.get("ground_type") or "ripping_rock"),
section_mode=str(design.get("section_mode") or "left_cut"),
ditch_side=design.get("ditch_side"),
ditch_type=str(design.get("ditch_type") or "standard"),
paved=bool(design.get("paved", False)),
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
@@ -295,14 +302,11 @@ def enforce_ford_surface_drops(
**stored_shoulder_extend(design), # 노견 확장(139-6)
**stored_tilt(design),
)
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
except (ValueError, KeyError):
continue
for key in ("status", "pavement_suggested", *USER_TOUCHED_KEYS):
if design.get(key) is not None:
recomputed[key] = design[key]
section["design"] = recomputed
changed += 1
return changed
jobs.append((section, design, (section.get("samples", []), elevation, options)))
return apply_designs(jobs, standard)
def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]:
+9 -98
View File
@@ -25,12 +25,6 @@ import {
writeState,
} from "../A00_Common/b_page_state";
import { kindChangedChainages } from "./B06_Section_Api_Culvert_Options";
import {
BERM_DEFAULT_DEPTH_M,
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
} from "@util/common_util_cross_berm";
import {
crossDesignChoices,
readBermDepths,
@@ -43,7 +37,15 @@ import type { StandardCrossSection } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { catalogRow } from "../common_util/common_util_structure_catalog";
// 소단 구간(C4) — common_util 로 옮김 · 부르는 곳 그대로(다시 내보냄).
import type { BermSpan } from "@util/common_util_berm_spans";
export {
bermSpansFromStructures,
bermSpecAt,
isBermType,
type BermSessionSpec,
type BermSpan,
} from "@util/common_util_berm_spans";
import { readKeyed, writeKeyed, type KeyedState } from "./B06_Section_Station_Keys";
/**
@@ -107,27 +109,6 @@ function persistKeyed(name: KeyedState, storageKey: string, values: Map<string,
else writeByKey(storageKey, JSON.stringify(Object.fromEntries(values)));
}
/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */
export interface BermSessionSpec {
width_m: number;
interval_m: number;
slope_deg: number;
/** 노면 끝 아래 깊이(m · ≥ 0 · 178-2). */
depth_m: number;
}
/**
* 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원.
*
* 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」).
* 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로
* 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다.
*/
export interface BermSpan extends BermSessionSpec {
start_m: number;
end_m: number;
}
/**
* 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록.
*
@@ -143,76 +124,6 @@ export function writeBermSpans(projectId: string, routeId: number, spans: BermSp
writeState("berm", spans, projectId, routeId);
}
/** 소단 = 표 형상 berm(323-4 · 파이썬 `load_berm_spans` 짝) — 종류 키로 안 가름. */
export const isBermType = (typeId: string): boolean => catalogRow(typeId)?.shape === "berm";
/**
* 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다
* (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리).
*
* 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을
* 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은
* 그 사본만 본다.
*/
export function bermSpansFromStructures(
structures: ReadonlyArray<{
type_id: string;
start_m?: number | null;
end_m?: number | null;
chainage_m?: number | null;
options?: Record<string, string | number>;
}>,
): BermSpan[] {
const spans: BermSpan[] = [];
for (const item of structures) {
if (!isBermType(item.type_id)) continue;
const anchor = item.chainage_m ?? item.start_m ?? null;
const start = item.start_m ?? anchor;
const end = item.end_m ?? anchor;
if (start === null || end === null) continue;
const options = item.options ?? {};
const number = (key: string, fallback: number): number => {
const parsed = Number(options[key]);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
const width = number("width_m", BERM_DEFAULT_WIDTH_M);
const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M);
if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2)
// 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0
const rawDepth = Number(options.depth_m);
const depth =
options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth)
? BERM_DEFAULT_DEPTH_M
: Math.max(rawDepth, 0);
spans.push({
start_m: Math.min(start, end),
end_m: Math.max(start, end),
width_m: width,
interval_m: interval,
slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG),
depth_m: depth,
});
}
return spans;
}
/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */
export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null {
const found = spans.find(
(span) =>
chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 &&
chainageM <= Math.max(span.start_m, span.end_m) + 1e-6,
);
return found
? {
width_m: found.width_m,
interval_m: found.interval_m,
slope_deg: found.slope_deg,
depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M,
}
: null;
}
/** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */
export interface RockBoundaryStore {
/** 측점키(누가거리 2자리) → 오프셋(m). `buildCrossPatches` 가 그대로 읽는다. */
+1
View File
@@ -12,6 +12,7 @@
① 짝(ts ↔ py · 거울 시험):
- `Engine_Design.py` ↔ `common_util_cross_design.ts` · `Engine_Areas.py` ↔ `common_util_cross_design_areas.ts` · `test_b06_cross_design_mirror.py`
- [저장] · [확정] 다시 셈(`Router_Design` enforce · 넓힘 뒤 설계 · 소단 구간)은 Node(`Engine_Design_Node.py` → design-calc `cross.design` · `cross.berm_spans`) · 파이썬 짝은 B07 · 추천 · Route_Save · Node 못 부를 때
- `Engine_Culvert.py` ↔ `common_util_culvert_sets.ts` · `logic/UI_Cross_Culvert_Const.ts` · `test_b06_culvert_sets_mirror.py` · `test_b06_pipe_wall_height_mirror.py`
- `Engine_Structures_Wall.py` ↔ `common_util_structure_walls.ts` · `test_b06_structure_walls_mirror.py`
- 지형 표본 = `common_util_surface_sampler` 하나(`Engine_SampleExtend` 도)
+107
View File
@@ -0,0 +1,107 @@
/* =============================================================================
* common_util_berm_spans.ts
* 소단 구간 — 구조물 목록(표 형상 berm)에서 구간 · 측점마다 제원(PLAN 472-4 C4 · 화면 파일
* `B06_Section_UI_Page_Persist.ts` 에서 옮김 · 그쪽이 다시 내보냄).
* 브라우저(시설 줄 · 다시 셈)와 서버 Node 입구(`design_calc_node` · 등록부 `cross.berm_spans`)가 같은 셈.
* 파이썬 짝 = `B06_Section_Engine_Structures_Wall.py` `load_berm_spans` · `berm_span_at`
* (Node 를 못 부를 때만 · 거울 시험 `test_272_berm_source.py`).
* ========================================================================== */
import {
BERM_DEFAULT_DEPTH_M,
BERM_DEFAULT_INTERVAL_M,
BERM_DEFAULT_SLOPE_DEG,
BERM_DEFAULT_WIDTH_M,
} from "./common_util_cross_berm";
import { catalogRow } from "./common_util_structure_catalog";
/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */
export interface BermSessionSpec {
width_m: number;
interval_m: number;
slope_deg: number;
/** 노면 끝 아래 깊이(m · ≥ 0 · 178-2). */
depth_m: number;
}
/**
* 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원.
*
* 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」).
* 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로
* 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다.
*/
export interface BermSpan extends BermSessionSpec {
start_m: number;
end_m: number;
}
/** 소단 = 표 형상 berm(323-4 · 파이썬 `load_berm_spans` 짝) — 종류 키로 안 가름. */
export const isBermType = (typeId: string): boolean => catalogRow(typeId)?.shape === "berm";
/**
* 구조물 목록에서 **소단 구간**을 뽑는다 — 사용자는 「구조물 배치」에서 놓는다
* (2026-09-07 사용자 확정: 별도 폼이 아니라 다른 옹벽·기슭막이와 같은 자리).
*
* 세션 열쇠 `berm` 은 그 결과를 담는 **사본**이다. 재계산(브라우저·서버)이 측점마다 값을
* 읽어야 하는데 구조물 목록은 비동기로 오므로, 목록이 바뀔 때마다 여기서 펴 두고 계산은
* 그 사본만 본다.
*/
export function bermSpansFromStructures(
structures: ReadonlyArray<{
type_id: string;
start_m?: number | null;
end_m?: number | null;
chainage_m?: number | null;
options?: Record<string, string | number>;
}>,
): BermSpan[] {
const spans: BermSpan[] = [];
for (const item of structures) {
if (!isBermType(item.type_id)) continue;
const anchor = item.chainage_m ?? item.start_m ?? null;
const start = item.start_m ?? anchor;
const end = item.end_m ?? anchor;
if (start === null || end === null) continue;
const options = item.options ?? {};
const number = (key: string, fallback: number): number => {
const parsed = Number(options[key]);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
};
const width = number("width_m", BERM_DEFAULT_WIDTH_M);
const interval = number("interval_m", BERM_DEFAULT_INTERVAL_M);
if (width <= 0) continue; // 폭이 0이면 계단이 없다(간격은 성토 한 단에 안 씀 · 178-2)
// 깊이 = 노면 끝 아래 · 음수는 0(도로보다 높을 수 없음) · 빈 값은 기본 1.0
const rawDepth = Number(options.depth_m);
const depth =
options.depth_m === undefined || options.depth_m === "" || !Number.isFinite(rawDepth)
? BERM_DEFAULT_DEPTH_M
: Math.max(rawDepth, 0);
spans.push({
start_m: Math.min(start, end),
end_m: Math.max(start, end),
width_m: width,
interval_m: interval,
slope_deg: number("slope_deg", BERM_DEFAULT_SLOPE_DEG),
depth_m: depth,
});
}
return spans;
}
/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */
export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null {
const found = spans.find(
(span) =>
chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 &&
chainageM <= Math.max(span.start_m, span.end_m) + 1e-6,
);
return found
? {
width_m: found.width_m,
interval_m: found.interval_m,
slope_deg: found.slope_deg,
depth_m: found.depth_m ?? BERM_DEFAULT_DEPTH_M,
}
: null;
}
+2
View File
@@ -24,6 +24,8 @@ NODE_IDS = frozenset(
"haul.plan",
"excavation.trench",
"corridor.mesh",
"cross.design",
"cross.berm_spans",
}
)
@@ -258,3 +258,7 @@
1. b05/b06의 조작과 데이터 흐름 프론트에서 계산하는지 백엔드에서 계산하는지를 브레인 참고문서를 하나 만들어서 코더들이 b05/b06을 수정할때 참고하게하자.
2. 재정리를 당연히 해야함. 단계적으로 계획서를 세워서 진행하고 데이터 저장의 관점을 보면 보안과 사용자 편의성을 위해 충돌이남. 그래서 로직들은 프론트와 백엔드에서 동작하게 하는 로직을 동시에 구성하고 최종적으로 하나의관리코드에서 선택적으로 할수 있게 반영하는건 어때? 물런 코드가 하나면 유지보수에 좋을테니 더 좋고 어때?
## 요구 274 (10-19 · 사용자 · 472-7 물음 답)
1. 측구는 변경이 되었으니 사용자가 이전에 선택한 값른 의미가 없어짐.
@@ -8,7 +8,7 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md
# 계산 목록 — TS · 파이썬 짝 · Node 묶음 · 실행 자리
- Node 묶음 — sc = server-calc · co = corridor · ex = b07-extent · 다시 지음 `npm run -s build:server-calc|corridor|b07-extent`.
- Node 묶음 — sc = server-calc · co = corridor · ex = b07-extent · dc = design-calc(등록부 입구 `A00_Common/design/logic/design_calc_node.ts` · 파이썬 `common_util_calc_runner.run_calc`) · 다시 지음 `npm run -s build:server-calc|corridor|b07-extent|design-calc`.
- 실행 자리 — 짝 = 브라우저 즉시 TS + [저장] 정본 파이썬 · ② = 같은 TS 를 브라우저 · Node 둘 다 · 브라우저 = 입력 도구 · 표시만 · 서버 = 무거운 GIS · 추천.
- 줄 더함 · 바꿈 = 새 계산 · 짝 · 묶음이 바뀐 커밋에서.
@@ -34,11 +34,11 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md
| P6 | 옮긴 시설 꼭짓점 세움 | `logic/B05_Profile_Grade_Seat.ts:32` · `common_util_structure_station.ts:356` | `B05_Profile_Router_Confirm.py:220 · 306` | — | 짝 · `test_427_1_follow_seat` · `test_459_route_seat_mirror` |
| P7 | 직선화 · 자유지정 · 쉬프트 · 시종점 | `logic/B05_Profile_UI_Profile_Straighten.ts` · `logic/design_profile_free.ts` · `logic/B05_Profile_UI_Profile_Pivot.ts` · `logic/B05_Profile_EndOffsets.ts` | — | — | 브라우저(편집값만 저장 → P2) |
| P8 | 종단 경고(기울기 · 법정 L · 최소 R · 피복) | `B05_Profile_UI_Panel_Fields.ts` · `logic/B05_Profile_UI_Profile_Guard.ts` · `B05_Profile_UI_Profile_MinCover.ts` · 경고 글 `logic/B05_Profile_UI_Profile_Alignment.ts` buildAlignment | `B05_Profile_Engine_Grade.py` · `B05_Profile_PageSettings.py` · 경고 글 `B05_Profile_Engine_Grade_Alignment.py` build_alignment | — | 짝 · [저장] · [확정] = `B06_Section_Server_Calc_Prebuild.py` refresh_alignment 가 경고만 다시 씀(기하 그대로) · `test_442_2_grade_limit_mirror` · `test_472_save_warnings` |
| C1 | 횡단 설계선 | `common_util_cross_design.ts:267` · `logic/B06_Section_Cross_Stage.ts:93` · `B06_Section_Cross_Refresh.ts:330` | `B06_Section_Engine_Design.py:195` | ex | 짝 · `test_b06_cross_design_mirror` |
| C2 | 단면적(절 · 성 · 측구 · 층따기) | `common_util_cross_design_areas.ts` | `B06_Section_Engine_Areas.py` | sc · ex | 짝 · 정본 = 표준 측점 파이썬 · 구조물 측점 Node |
| C3 | 확정 보정(포장 · 세월교 · 기울임) | `logic/B06_Section_Cross_Enforce.ts:40` | `B06_Section_Router_Design.py` · `B06_Section_Engine_Tilt.py` | — | 짝 · `test_349_0n_cross_enforce_mirror` |
| C4 | 소단 구간(구조물 → 구간) | `B06_Section_UI_Page_Persist.ts:157`(시설 줄이 부름) | `B06_Section_Engine_Structures_Wall.py:124` | — | 짝 · `test_272_berm_source` |
| C5 | 소단 사면 점 | `common_util_cross_berm.ts` | `common_util_cross_berm.py` | sc · co · ex | 짝 · `test_178_2_berm_fill_mirror` |
| C1 | 횡단 설계선 | `common_util_cross_design.ts:267` · `logic/B06_Section_Cross_Stage.ts:93` · `B06_Section_Cross_Refresh.ts:330` | `B06_Section_Engine_Design.py:195`(B07 · 추천 · Route_Save · B05 확정 · 넓힘 찾기) | dc · ex | ② · [저장] · [확정] 다시 셈 = Node(`B06_Section_Engine_Design_Node.py` 측점 묶음 · 파이썬은 입력 조립) · `test_b06_cross_design_mirror` · `test_472_1_golden` 설계선 점 1e-4 |
| C2 | 단면적(절 · 성 · 측구 · 층따기) | `common_util_cross_design_areas.ts` | `B06_Section_Engine_Areas.py` | dc · sc · ex | ② · C1 과 한 셈 · 정본 = 표준 · 구조물 측점 모두 Node |
| C3 | 확정 보정(포장 · 세월교 · 기울임) | `logic/B06_Section_Cross_Enforce.ts:40` | `B06_Section_Router_Design_Ranges.py` · `B06_Section_Engine_Tilt.py` | dc(다시 세운 설계) | 구간 판정 짝 · `test_349_0n_cross_enforce_mirror` |
| C4 | 소단 구간(구조물 → 구간) | `common_util_berm_spans.ts`(시설 줄 · 다시 셈이 부름) | `B06_Section_Engine_Structures_Wall.py:124`(Node 못 부를 때만) | dc | ② · `test_272_berm_source` · `test_472_3_calc_runner` |
| C5 | 소단 사면 점 | `common_util_cross_berm.ts` | `common_util_cross_berm.py` | dc · sc · co · ex | C1 과 한 셈 · `test_178_2_berm_fill_mirror` |
| C6 | 맹암거 얹기 · 기울임 | `common_util_structure_underdrain.ts` | `B06_Section_Engine_Tilt.py` | — | 짝 · 거울 시험 없음 |
| C7 | 반폭 맞춤 · 성토 5 m 경고 | `logic/B06_Section_UI_Cross_Fit.ts` · `logic/B06_Section_UI_Cross_FillSlope_Warn.ts` | — | ex(Fit) | 브라우저 |
| F1 | 시설 따라감(노선 바뀜) | `common_util_route_follow.ts:291` · `logic/B05_Profile_Pipeline_Follow.ts` · `logic/B05_Profile_Structure_Follow.ts` | `common_util_route_follow.py:120` | — | 짝 · `test_410_follow_xy` · `test_344_2_route_pairs` |
@@ -64,7 +64,7 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md
|---|---|---|---|---|
| X1 | 단면 모양 기본값 | `"left_cut"`(`B06_Section_Cross_Refresh.ts:438`) | `default_section_modes`(`B06_Section_Router_Design.py:402`) | 사용자 협의 · 손대지 않음 |
| X2 | 지반 표본 출처 · 입력 선 | 격자 `.bin` · `/route/plan` planned 선 | npz `sample_xy` · 저장 경로 폴리라인 | 노선 초안 없는 차례 = 정본 표본 유지로 막음 |
| X3 | 단면적 두 갈래 | 전부 TS | 표준 측점 파이썬 · 구조물 측점 Node | 1e-4 ㎡ 차 |
| X3 | 단면적 두 갈래 | 전부 TS | 모두 Node(472-4 ②) | 풀림 · 남은 차 = 설계선 점 반올림 동점 1e-4 m(파이썬 round 짝수 쪽 · TS 위쪽) |
| X4 | 평면 발자국 | TS PlanFootprint | B07 파이썬 PlanSlope | 거울 시험 |
| X5 | 경고 기준표 | `B05_Profile_UI_Panel_Fields.ts` 안 표 | `FOREST_ROAD_PROFILE_CRITERIA` | 거울 시험 · 자료 두 벌 |
| X6 | 노선 바뀜 넘기는 칸 | `B06_Section_Station_Keys.ts:46` 근처 | `B05_Profile_Route_Save.py:275-279` CARRY_INPUT_KEYS · ROUTE_CARRY_KEYS | 손으로 맞춘 목록 둘 · 거울 없음 |
+1
View File
@@ -23,6 +23,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
| 두 보기가 보는 파생값은 페이지 한 벌이 숨은 동안도 셈 · 보기는 그리기만 · 접힘 = 그리기만 건너뜀 | `design_facility_lane.ts:1-12` · `B05_Profile_UI_Profile_Render.ts:211-226` | `test_272_hidden_summary` · `test_271_facility_lane` |
| 되돌리기 걸음 = 사용자 조작으로 값이 실제로 바뀐 때만 · 빈 칸(「""」「{}」「null」) = 칸 없음 · 빈 맵 persist = 칸 지움 | `B05_Profile_UI_Profile_History.ts:50 · 235-236` · `B06_Section_UI_Page_Link_Session.ts:97-105 · 153` | `test_272_history_empty_cells` |
| 짝 없는 새 측점(구조물 · 추가 측점 · 새 관) = 계획선 꼭짓점 안 세움 · 짝 있는 옮긴 측점 = 꼭짓점 따라감 | `common_util_structure_station.ts:356` · `B05_Profile_Router_Confirm.py:306-400` | `test_427_1_follow_seat` · `test_459_route_seat_mirror` |
| 구조물 측점 다시 뜸([저장] 상단측 차례 · 측점 맞춤) = 같은 자리 옛 줄의 상단측 출처(`user` 면 값도) · 포장 제안 표기 들고 감 · 새 자리 포장 표기 = 지금 계획선 | `B05_Profile_Router_Confirm.py:426-465` | `test_472_station_user_fields` |
| [저장] · [확정] = 서버가 입력만으로 정본 다시 셈 · 브라우저 결과(면적 · 표본 · 측점) 받아 적지 않음 | `design_save.ts:58-68` · `B06_Section_Router_Confirm.py:203-215` | `test_b06_save_recompute` |
| 자동저장(디바운스 PUT) 없음 · 화면 잠금 = [저장] · [확정] · [초기화] · 추천 [적용]만 | `design_save.ts:249` | — |
| 측점 열쇠 = `common_util_station_key` 한 벌 · 노선 바뀜 = 관 · 구조물 좌표(x · y) 따라감 · 누가거리 다시 매김 | `common_util_station_key.ts:32` · `common_util_route_follow.ts:291` | `test_344_3_station_key_mirror` · `test_410_follow_xy` |
@@ -0,0 +1,5 @@
/* 472-4 C6 거울 헬퍼 — 맹암거 얹기 TS 짝(vite ssr) · test_472_4_underdrain_mirror.py 가 JSON 으로 받음. */
export {
attachUnderdrainSpecs,
underdrainSpecsFrom,
} from "../../common_util/common_util_structure_underdrain";
+4 -1
View File
@@ -42,8 +42,11 @@ def test_글은_재계산에서도_남는다(monkeypatch):
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: [])
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
# 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움
monkeypatch.setattr(
design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]}
design_mod,
"compute_cross_designs",
lambda jobs, standard: [{"paved": kw["paved"]} for _s, _z, kw in jobs],
)
sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note": "메모"}}]
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1
@@ -202,7 +202,8 @@ def test_좌측_패널_칸_구간_깊이_나름():
"""옛 구조물 옵션 depth_m → 세션 구간 → 측점 제원(기본값) · 서버 payload(같은 꼴).
좌측 칸은 194-1 에서 숨김 — 옮기기는 횡단도 ▲▼(`test_194_1_berm_depth`)."""
source = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
# 소단 구간 셈은 472-4 C4 에서 `common_util_berm_spans.ts` 로 옮김
source = (ROOT / "common_util" / "common_util_berm_spans.ts").read_text(encoding="utf-8")
assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
assert "depthM: spec.depth_m" in refresh
+4 -1
View File
@@ -59,8 +59,11 @@ def test_CAD_칸은_재계산에서도_남는다(monkeypatch):
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: [])
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
# 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움
monkeypatch.setattr(
design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]}
design_mod,
"compute_cross_designs",
lambda jobs, standard: [{"paved": kw["paved"]} for _s, _z, kw in jobs],
)
sections = [{"chainage_m": 40.0, "design": {"paved": False, "structure_note_cad": CAD}}]
assert design_mod.enforce_pavement_ranges({}, sections, Path("."), None) == 1
+20
View File
@@ -19,6 +19,7 @@ X3 면적 두 갈래(구조물 = 관 351 하나 · 두 길 다 Node TS 로 셈)
from __future__ import annotations
import copy
import json
from dataclasses import dataclass
@@ -131,6 +132,25 @@ def test_두_길_사이_알려진_차만(got, pair) -> None:
assert not gone, f"안 나타난 알려진 차 {gone} — KNOWN 에서 지움"
def test_저장_설계선_점_정본과_1e4_안(tmp_path) -> None:
"""[저장] 다시 셈 설계선(472-4 ② = Node TS) — 정본과 점 수 · offset 같음 · 고도 1e-4 m 안.
알려진 차 = 반올림 동점(파이썬 round 짝수 쪽 · TS 위쪽) 13 점(브레인 10-19 승인).
"""
detail = copy.deepcopy(paths.read("detail.json"))
before = {s["chainage_m"]: s["design"]["design_line"] for s in detail["cross_sections"]}
sections, _ = paths._save_tail(detail, paths.stage_project(tmp_path / "project"))
moved = 0
for section in sections:
old, new = before[section["chainage_m"]], section["design"]["design_line"]
assert [p["offset_m"] for p in new] == [p["offset_m"] for p in old], section["chainage_m"]
for a, b in zip(old, new, strict=True):
gap = abs(a["elevation_m"] - b["elevation_m"])
assert gap <= 1e-4 + 1e-9, (section["chainage_m"], a, b)
moved += gap > 0
assert moved == 13, f"반올림 동점 점 수 {moved} — 알려진 차(13)와 다름"
def test_화면_참값() -> None:
stored = GOLDEN["stored"]
assert stored["final_cumulative_m3"] == -17570.7
+7 -3
View File
@@ -40,6 +40,8 @@ NODE_IDS = {
"haul.plan",
"excavation.trench",
"corridor.mesh",
"cross.design",
"cross.berm_spans",
}
pytestmark = pytest.mark.skipif(
@@ -196,7 +198,7 @@ def test_id_마다_실행기_있음(loaded) -> None:
if row["site"] == "browser_only":
ok = kind == "browser" and _has_name(ref)
elif kind == "node":
ok = ref == row["id"] and ref in NODE_IDS
ok = ref in NODE_IDS # 한 셈에서 나오는 값(면적 · 소단 사면)은 그 셈의 id
else:
ok = kind == "py" and _has_name(ref)
ok = ok and ("until" not in row or kind == "py")
@@ -205,8 +207,10 @@ def test_id_마다_실행기_있음(loaded) -> None:
if not ok:
bad.append(row["id"])
assert bad == []
node_rows = {row["id"] for row in loaded["registry"] if row["runner"].startswith("node:")}
assert node_rows == NODE_IDS
node_runs = {
row["runner"][5:] for row in loaded["registry"] if row["runner"].startswith("node:")
}
assert node_runs == NODE_IDS
def test_묶음_STAGE_DEPS_등록부_입력과_같음(loaded) -> None:
@@ -68,3 +68,36 @@ def test_실행기_값_server_calc_와_같음() -> None:
def test_Node_실행기_아닌_id_는_ValueError() -> None:
with pytest.raises(ValueError, match="stations.list"):
runner.run_calc(["masshaul.cumulative", "stations.list"], {})
# ── 472-4 ② — 횡단 설계선 · 소단 구간 Node 길 ──────────────────────────────────
def test_소단_구간_Node_와_파이썬_짝_같음(tmp_path, monkeypatch) -> None:
from test_272_berm_source import STRUCTURE_CASES, _project
from B06_Section import B06_Section_Engine_Design_Node as node_mod
from B06_Section.B06_Section_Engine_Structures_Wall import berm_span_at, load_berm_spans
calls = []
real = node_mod.run_calc
monkeypatch.setattr(
node_mod, "run_calc", lambda ids, inputs: calls.append(ids) or real(ids, inputs)
)
chainages = [0.0, 5.0, 50.0, 65.0, 80.0, 100.0, 150.0, 250.0]
for index, structures in enumerate(STRUCTURE_CASES):
root = _project(tmp_path / f"c{index}", structures)
got = node_mod.berm_specs_at(root, chainages)
spans = load_berm_spans(root)
want = [berm_span_at(spans, c) for c in chainages]
assert got == [pytest.approx(w) if w else w for w in want], index
assert len(calls) == len(STRUCTURE_CASES) # 파이썬 물러섬 아님
def test_횡단_설계_못_세운_측점은_None() -> None:
from B06_Section.B06_Section_Engine_Design_Node import compute_cross_designs
ground = [{"offset_m": o, "elevation_m": 100.0 + 0.3 * o} for o in range(-20, 21)]
options = {"ground_type": "soil", "section_mode": "left_cut"}
got = compute_cross_designs([(ground, 100.0, options), (ground, None, options)], None)
assert got[0]["cut_area_m2"] > 0 and got[1] is None
@@ -0,0 +1,120 @@
"""PLAN 472-4 ② C6 (요구 273) — 맹암거 얹기 거울: TS `common_util_structure_underdrain` ↔ 파이썬
`B06_Section_Engine_Tilt.underdrains_from` · `attach_underdrain_specs`.
못박는 것
- 구조물 목록 → 제원(높이 · 경사 · 빈 값 · 0 · 음수 · 글 · 맹암거 아닌 종류) 두 쪽 같음.
- 기준 측점(±0.02 m) 얹기 · 얹은 수 같음.
- 알려진 차 하나 — 옛 `underdrain` 이 남은 측점: TS 는 걷음 · 파이썬은 둠
(서버는 파일에서 새로 읽어 옛 값 없음 · Node 로 옮길 때 한 벌).
./venv/Scripts/python.exe -m pytest resources/tester/test_472_4_underdrain_mirror.py -q
"""
from __future__ import annotations
import copy
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
from B06_Section.B06_Section_Engine_Tilt import attach_underdrain_specs, underdrains_from
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
reason="프론트엔드 의존성 미설치",
)
def _drain(at: float, **options) -> dict:
return {
"type_id": "cross_underdrain",
"placement": "point",
"chainage_m": at,
"options": options,
}
STRUCTURES = [
_drain(40.0, culvert_height_m=1.2, culvert_slope_pct=3),
_drain(80.0, culvert_height_m="", culvert_slope_pct="2.5"),
_drain(120.0, culvert_height_m=0, culvert_slope_pct=-1),
_drain(160.0),
{"type_id": "berm", "placement": "interval", "start_m": 0.0, "end_m": 50.0, "options": {}},
]
SECTIONS = [
{"chainage_m": 40.01},
{"chainage_m": 79.99},
{"chainage_m": 120.0},
{"chainage_m": 160.03}, # 0.02 밖
{"chainage_m": 200.0, "underdrain": {"chainage_m": 200.0}}, # 옛 값 — 알려진 차
]
@pytest.fixture(scope="module")
def ts(tmp_path_factory) -> dict:
work = tmp_path_factory.mktemp("u4724")
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_472_4_underdrain_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
check=True,
capture_output=True,
timeout=300,
)
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_472_4_underdrain_entry.js", bundle)
job = json.dumps({"structures": STRUCTURES, "sections": SECTIONS})
script = (
f"const m = await import({json.dumps(bundle.as_uri())});"
f"const job = {job};"
"const specs = m.underdrainSpecsFrom(job.structures);"
"const count = m.attachUnderdrainSpecs(job.sections, specs);"
"console.log(JSON.stringify({specs, count, sections: job.sections}));"
)
out = subprocess.run( # noqa: S603
["node", "--input-type=module", "-e", script],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
return json.loads(out.stdout)
def _python() -> tuple[list[dict], int, list[dict]]:
specs = underdrains_from([StructureInstance(**item) for item in STRUCTURES])
sections = copy.deepcopy(SECTIONS)
return specs, attach_underdrain_specs(sections, specs), sections
def test_제원_같음(ts) -> None:
specs, _count, _sections = _python()
assert ts["specs"] == specs
def test_얹기_같음_알려진_차_하나(ts) -> None:
_specs, count, sections = _python()
assert ts["count"] == count == 3
assert ts["sections"][:4] == sections[:4]
# 알려진 차 — 옛 underdrain: TS 걷음 · 파이썬 둠
assert "underdrain" not in ts["sections"][4] and "underdrain" in sections[4]
@@ -0,0 +1,130 @@
"""PLAN 472 덧 (요구 272) — 구조물 측점을 다시 떠도 사용자 상단측 · 포장 제안 표기가 남음.
증상 — [저장] 상단측 차례(`POST /route/confirm` → `_append_irregular_cross_sections`) · 측점 맞춤
(`ensure_structure_stations`)이 `_merge_irregular_into_longitudinal` 로 구조물 측점 줄을 새 줄로
통째 갈음 → 같은 자리 옛 줄의 `uphill_side_source: user`(상단측 손 지정 · 값은 자동으로 돌아감) ·
`pavement_*`(포장 제안 표기)가 사라짐(검증_sub8 측점 351).
고침 — 같은 자리(누가거리) 옛 줄에서 `STATION_CARRY_KEYS` 를 들고 감 · 사용자 상단측은 새 자동값을
이김 · 옛 줄 없는 새 자리는 포장 표기를 지금 계획선으로 셈.
X6 — 옮긴 측점이 들고 가는 횡단 입력 TS `ROW_CARRY_KEYS` = 파이썬 `CARRY_INPUT_KEYS`(거울).
./venv/Scripts/python.exe -m pytest resources/tester/test_472_station_user_fields.py -q
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Router_Confirm import ( # noqa: E402
_merge_irregular_into_longitudinal,
)
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS # noqa: E402
RELATIVE = "B06_Section/longitudinal/longitudinal.json"
CRITERIA = {
"max_grade_pct": 14.0,
"max_reverse_grade_pct": 5.0,
"min_vertical_radius_m": 100.0,
"min_curve_length_m": 20.0,
"min_tangent_length_m": 20.0,
"vertical_curve_skip_delta_pct": 5.0,
"design_speed_kph": 20,
"terrain_type": "special",
"paved": False,
"main_direction": "auto",
"balance_segment_length_m": None,
"start_elevation_offset_m": 0.0,
"end_elevation_offset_m": 0.0,
"enforce_pipe_clearance": False,
"resolved_main_direction": "ascending",
}
def _row(at: float, kind: str = "regular", **extra) -> dict:
return {"chainage_m": at, "kind": kind, "station_id": f"s{at}", "uphill_side": "left", **extra}
def _pave(grade: float) -> dict:
return {
"pavement_suggested": grade > 14.0,
"pavement_grade_pct": grade,
"pavement_grade_limit_pct": 14.0,
}
def _write(tmp_path: Path) -> Path:
# 계획선 0 → 100 m 고르게 20 % 오르막(포장 상한 14 % 넘음)
samples = [{"chainage_m": float(s), "elevation_m": 0.2 * s} for s in range(0, 101, 5)]
stations = [
_row(0.0, "bp", uphill_side_source="user", **_pave(20.0)),
_row(20.0, uphill_side_source="user", **_pave(20.0)),
_row(
35.0,
"irregular",
structure="관 D1000",
uphill_side="right",
uphill_side_source="user",
**_pave(20.0),
), # fmt: skip
_row(40.0, uphill_side="right", uphill_side_source="user", **_pave(20.0)),
_row(100.0, "ep", uphill_side_source="user", **_pave(20.0)),
]
path = tmp_path / RELATIVE
path.parent.mkdir(parents=True)
longitudinal = {
"stations": stations,
"samples": samples,
"design_profiles": [{"criteria": CRITERIA, "samples": samples}],
}
path.write_text(json.dumps(longitudinal), encoding="utf-8")
return path
def _fresh(at: float, **extra) -> dict:
"""새로 뜬 구조물 측점 — 상단측은 지형 자동값 · 포장 표기 · 출처 칸 없음."""
return {"chainage_m": at, "kind": "irregular", "station_id": f"n{at}", "uphill_side": "left",
"structure": "관 D1000", **extra} # fmt: skip
def _merged(tmp_path: Path, fresh: list[dict]) -> dict[float, dict]:
path = _write(tmp_path)
_merge_irregular_into_longitudinal(tmp_path, RELATIVE, fresh)
rows = json.loads(path.read_text(encoding="utf-8"))["stations"]
return {row["chainage_m"]: row for row in rows}
def test_같은_자리_다시_떠도_사용자_상단측_포장_표기_남음(tmp_path) -> None:
rows = _merged(tmp_path, [_fresh(35.0)])
row = rows[35.0]
assert row["station_id"] == "n35.0" # 기하 · 글은 새 줄
assert row["uphill_side"] == "right" and row["uphill_side_source"] == "user"
assert row["pavement_suggested"] is True and row["pavement_grade_pct"] == 20.0
def test_격자_자리에_구조물이_얹혀도_사용자_상단측_남음(tmp_path) -> None:
rows = _merged(tmp_path, [_fresh(35.0), _fresh(40.0, grid=True)])
assert rows[40.0]["uphill_side"] == "right" and rows[40.0]["uphill_side_source"] == "user"
assert rows[40.0]["pavement_grade_limit_pct"] == 14.0
def test_새_자리는_포장_표기를_지금_계획선으로(tmp_path) -> None:
rows = _merged(tmp_path, [_fresh(55.0)])
assert 35.0 not in rows # 옛 구조물 측점은 걷힘
row = rows[55.0]
assert row["uphill_side"] == "left" and "uphill_side_source" not in row # 새 자리 = 지형 자동
assert row["pavement_suggested"] is True
assert row["pavement_grade_pct"] == 20.0 and row["pavement_grade_limit_pct"] == 14.0
def test_X6_옮긴_측점_횡단_입력_TS_파이썬_한_벌() -> None:
source = (ROOT / "B06_Section/logic/B06_Section_Station_Swap.ts").read_text(encoding="utf-8")
body = re.search(r"export const ROW_CARRY_KEYS = \[(.*?)\] as const;", source, re.S)
assert body is not None
assert tuple(re.findall(r'"([a-z_0-9]+)"', body.group(1))) == CARRY_INPUT_KEYS
@@ -31,6 +31,8 @@ TSC = PROJECT_ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
# 값이 소수점 아래에서 갈리는지 보려면 자릿수를 그대로 비교해야 한다. 두 쪽 다 반올림해
# 내보내므로 완전 일치를 기대하되, 부동소수 마지막 자리만 어긋나는 것은 허용한다.
# 알려진 차(472-4 ②) — 반올림 동점(…5)에서 파이썬 round 는 짝수 쪽 · TS 는 위쪽이라 설계선 점이
# 1e-4 m 갈릴 수 있음(이 시험 입력엔 없음 · [저장] 정본은 Node TS 쪽 · test_472_1_golden).
_TOLERANCE = 1e-9
@@ -146,8 +146,11 @@ def test_enforce_turns_paved_on_and_off(monkeypatch):
monkeypatch.setattr(design_mod, "ford_surface_drops", lambda root: [])
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
# 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움
monkeypatch.setattr(
design_mod, "compute_cross_design", lambda samples, elev, **kw: {"paved": kw["paved"]}
design_mod,
"compute_cross_designs",
lambda jobs, standard: [{"paved": kw["paved"]} for _s, _z, kw in jobs],
)
sections = [
{"chainage_m": 20.0, "design": {"paved": False}}, # 범위 안 · 끔 → 켬
@@ -80,7 +80,12 @@ def _install_fakes(monkeypatch) -> dict:
}
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 200.0)])
monkeypatch.setattr(design_mod, "compute_cross_design", fake_compute)
# 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움
monkeypatch.setattr(
design_mod,
"compute_cross_designs",
lambda jobs, standard: [fake_compute(s, z, standard=standard, **kw) for s, z, kw in jobs],
)
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})
return seen
+6 -1
View File
@@ -120,7 +120,12 @@ def _fake_engine(monkeypatch):
return {"paved": True, "cut_area_m2": 1.0, "fill_area_m2": 2.0}
monkeypatch.setattr(design_mod, "pavement_ranges", lambda root: [(0.0, 200.0)])
monkeypatch.setattr(design_mod, "compute_cross_design", fake_compute)
# 472-4 ② — [저장] 다시 셈은 측점 묶음(Node) 한 번 · 그 자리를 갈아 끼움
monkeypatch.setattr(
design_mod,
"compute_cross_designs",
lambda jobs, standard: [fake_compute(s, z, standard=standard, **kw) for s, z, kw in jobs],
)
monkeypatch.setattr(design_mod, "design_elevation_from_longitudinal", lambda lon, ch: 100.0)
monkeypatch.setattr(design_mod, "curve_widening_args", lambda section: {})