Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-19 09:28:19 +09:00
25 changed files with 737 additions and 286 deletions
@@ -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]
+58 -148
View File
@@ -1,18 +1,13 @@
"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산.
짝(349-0n): `pavement_ranges` · `ford_surface_drops` · `paved_at` · `ford_drop_at` ↔
`B06_Section/logic/B06_Section_Cross_Enforce.ts` · 거울 시험 `test_349_0n_cross_enforce_mirror.py`.
포장 · 세월교 구간 읽기 · 판정(짝 349-0n) = `B06_Section_Router_Design_Ranges.py` · 다시 내보냄.
"""
import json
import logging
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
from B06_Section.B06_Section_Engine_Design import (
ROUTE_CARRY_KEYS,
compute_cross_design,
@@ -20,7 +15,22 @@ 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
# 포장 · 세월교 구간(나눈 파일) — 이 모듈 이름으로 다시 내보냄(부르는 곳 · 시험 갈아 끼움 그대로).
from B06_Section.B06_Section_Router_Design_Ranges import ( # noqa: F401
ford_drop_at,
ford_pavement_ranges,
ford_surface_drops,
paved_at,
pavement_ranges,
pavement_suggestions,
)
from common_util.common_util_cross_berm import (
BERM_DEFAULT_DEPTH_M,
BERM_DEFAULT_INTERVAL_M,
@@ -29,15 +39,8 @@ from common_util.common_util_cross_berm import (
BermSpec,
)
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from common_util.common_util_structure_station import EDGE_TOLERANCE_M
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
logger = logging.getLogger(__name__)
# 세월교 노면 하강이 걸리는 측점 판정 허용 오차(m) — 세트 부착(`attach_culvert_sets`)과
# 같은 기준이라 "구체가 그려진 측점"과 "노면이 내려간 측점"이 어긋나지 않는다.
_FORD_DROP_TOLERANCE_M = 0.02
PREVIEW_DESIGN_FIELDS = (
"ground_type",
"roadbed_width_m",
@@ -52,98 +55,6 @@ PREVIEW_DESIGN_FIELDS = (
)
def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]:
mapping: dict[float, bool] = {}
stations = longitudinal.get("stations")
for station in stations if isinstance(stations, list) else []:
suggested = station.get("pavement_suggested")
if isinstance(suggested, bool):
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested
return mapping
def pavement_ranges(project_root: Path) -> list[tuple[float, float]]:
"""포장으로 볼 누가거리 구간 — 구조물 정본 G군 + 물넘이포장(포장 필수).
포장 지정은 **사용자가 구간으로 준다**(2026-08-28 사용자 확정). 종단경사 자동 판정은
더 이상 포장을 켜지 않는다 — 경고 표기(`pavement_suggested`)만 남는다.
물넘이포장은 콘크리트 등으로 노면을 만드는 시설이라 범위 안이 항상 포장이다.
읽기 실패는 비치명 — 빈 목록이면 사용자 지정이 없는 것과 같다.
"""
ranges: list[tuple[float, float]] = []
try:
types = structure_type_map()
for structure in load_structures(str(project_root))[1]:
definition = types.get(structure.type_id)
if definition is None or definition.group != "G":
continue
anchor = structure.chainage_m
start = structure.start_m if structure.start_m is not None else anchor
end = structure.end_m if structure.end_m is not None else anchor
if start is None or end is None:
continue
ranges.append((min(float(start), float(end)), max(float(start), float(end))))
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)")
return ranges + ford_pavement_ranges(project_root)
def ford_pavement_ranges(project_root: Path | None) -> list[tuple[float, float]]:
"""물넘이포장 범위(기준 ± 월류 폭/2) — 포장 필수 · 측구 없음(286-2). 판정은 `paved_at`."""
if project_root is None:
return []
sets = load_culvert_sets(project_root).items()
halves = [
(at, float(s.get("span_m") or 0.0) / 2.0)
for at, s in sets
if s.get("type") == "ford_pavement"
]
return [(at - half, at + half) for at, half in halves]
def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]:
"""세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)].
구체 위 노면은 월류 높이만큼 낮게 앉는다(2026-08-30 사용자 확정). 계획고를 그만큼
내려 잡아야 측벽·바닥판이 맞고 절·성토 면적도 따라온다. 포장 구간과 같은 방식으로
관 지점 정본에서 읽는다 — 읽기 실패는 비치명(하강 없음으로 본다).
"""
if project_root is None:
return []
drops: list[tuple[float, float]] = []
try:
for chainage, spec in load_culvert_sets(project_root).items():
if spec.get("type") != "ford":
continue
depth = float(spec.get("overflow_depth_m") or 0.0)
if depth > 0:
drops.append((chainage, depth))
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
logger.exception("B06 세월교 월류 높이를 읽지 못했습니다 (노면 하강 없음으로 본다)")
return drops
def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float:
"""그 측점의 노면 하강량(m). 구체가 앉은 측점만 — 붙는 기준은 세트 부착과 같다."""
for at, depth in drops:
if abs(chainage_m - at) <= _FORD_DROP_TOLERANCE_M:
return depth
return 0.0
# 범위 끝 허용오차(m) — 같은 자리 거리 한 곳(323-7)
_PAVED_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M
def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool:
"""이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시).
브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1).
"""
tol = _PAVED_EDGE_TOLERANCE_M
return any(start - tol <= chainage_m <= end + tol for start, end in ranges)
# 사용자 조작값 — 다시 계산해도 그대로 승계한다. **이 목록이 서버의 유일한 한 벌이다.**
#
# 브라우저 쪽 짝은 `B06_Section_Cross_Refresh.ts` 의 `PRESERVED_KEYS` 다(+ `status`·
@@ -224,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]],
@@ -240,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):
@@ -251,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
),
@@ -273,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(
@@ -297,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
),
@@ -333,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(
@@ -356,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):
@@ -367,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
),
@@ -389,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]:
@@ -0,0 +1,113 @@
"""B06 포장 · 세월교 구간 읽기와 판정 — `B06_Section_Router_Design.py` 에서 나눔(472-4 · 동작 같음).
짝(349-0n): `pavement_ranges` · `ford_surface_drops` · `paved_at` · `ford_drop_at` ↔
`B06_Section/logic/B06_Section_Cross_Enforce.ts` · 거울 시험 `test_349_0n_cross_enforce_mirror.py`.
부르는 쪽은 `B06_Section_Router_Design` 이름으로 씀(다시 내보냄 · 시험이 그 이름을 갈아 끼움).
"""
import logging
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
from common_util.common_util_structure_station import EDGE_TOLERANCE_M
logger = logging.getLogger(__name__)
# 세월교 노면 하강이 걸리는 측점 판정 허용 오차(m) — 세트 부착(`attach_culvert_sets`)과
# 같은 기준이라 "구체가 그려진 측점"과 "노면이 내려간 측점"이 어긋나지 않는다.
_FORD_DROP_TOLERANCE_M = 0.02
def pavement_suggestions(longitudinal: dict[str, Any]) -> dict[float, bool]:
mapping: dict[float, bool] = {}
stations = longitudinal.get("stations")
for station in stations if isinstance(stations, list) else []:
suggested = station.get("pavement_suggested")
if isinstance(suggested, bool):
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = suggested
return mapping
def pavement_ranges(project_root: Path) -> list[tuple[float, float]]:
"""포장으로 볼 누가거리 구간 — 구조물 정본 G군 + 물넘이포장(포장 필수).
포장 지정은 **사용자가 구간으로 준다**(2026-08-28 사용자 확정). 종단경사 자동 판정은
더 이상 포장을 켜지 않는다 — 경고 표기(`pavement_suggested`)만 남는다.
물넘이포장은 콘크리트 등으로 노면을 만드는 시설이라 범위 안이 항상 포장이다.
읽기 실패는 비치명 — 빈 목록이면 사용자 지정이 없는 것과 같다.
"""
ranges: list[tuple[float, float]] = []
try:
types = structure_type_map()
for structure in load_structures(str(project_root))[1]:
definition = types.get(structure.type_id)
if definition is None or definition.group != "G":
continue
anchor = structure.chainage_m
start = structure.start_m if structure.start_m is not None else anchor
end = structure.end_m if structure.end_m is not None else anchor
if start is None or end is None:
continue
ranges.append((min(float(start), float(end)), max(float(start), float(end))))
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
logger.exception("B06 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)")
return ranges + ford_pavement_ranges(project_root)
def ford_pavement_ranges(project_root: Path | None) -> list[tuple[float, float]]:
"""물넘이포장 범위(기준 ± 월류 폭/2) — 포장 필수 · 측구 없음(286-2). 판정은 `paved_at`."""
if project_root is None:
return []
sets = load_culvert_sets(project_root).items()
halves = [
(at, float(s.get("span_m") or 0.0) / 2.0)
for at, s in sets
if s.get("type") == "ford_pavement"
]
return [(at - half, at + half) for at, half in halves]
def ford_surface_drops(project_root: Path | None) -> list[tuple[float, float]]:
"""세월교가 앉은 측점의 노면 하강량 — [(누가거리, 월류 높이 m)].
구체 위 노면은 월류 높이만큼 낮게 앉는다(2026-08-30 사용자 확정). 계획고를 그만큼
내려 잡아야 측벽·바닥판이 맞고 절·성토 면적도 따라온다. 포장 구간과 같은 방식으로
관 지점 정본에서 읽는다 — 읽기 실패는 비치명(하강 없음으로 본다).
"""
if project_root is None:
return []
drops: list[tuple[float, float]] = []
try:
for chainage, spec in load_culvert_sets(project_root).items():
if spec.get("type") != "ford":
continue
depth = float(spec.get("overflow_depth_m") or 0.0)
if depth > 0:
drops.append((chainage, depth))
except Exception: # noqa: BLE001 — 정본을 못 읽어도 설계 계산은 이어 간다
logger.exception("B06 세월교 월류 높이를 읽지 못했습니다 (노면 하강 없음으로 본다)")
return drops
def ford_drop_at(chainage_m: float, drops: list[tuple[float, float]]) -> float:
"""그 측점의 노면 하강량(m). 구체가 앉은 측점만 — 붙는 기준은 세트 부착과 같다."""
for at, depth in drops:
if abs(chainage_m - at) <= _FORD_DROP_TOLERANCE_M:
return depth
return 0.0
# 범위 끝 허용오차(m) — 같은 자리 거리 한 곳(323-7)
_PAVED_EDGE_TOLERANCE_M = EDGE_TOLERANCE_M
def paved_at(chainage_m: float, ranges: list[tuple[float, float]]) -> bool:
"""이 측점을 포장으로 볼 것인가 — 노면공 · 물넘이 범위 안만 포장, 밖은 비포장(저장값 무시).
브라우저 짝 = `B06_Section_Cross_Refresh.ts` `pavedBySpecs`(같은 규칙 · 138-1).
"""
tol = _PAVED_EDGE_TOLERANCE_M
return any(start - tol <= chainage_m <= end + tol for start, end in ranges)
+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` 도)