Files
Aislo/B06_Section/B06_Section_Router_Design.py
T
eomsangdonandClaude Opus 5 e371c2754a feat(B05,B06): 물넘이 3D 파임 + 독립 기슭막이 횡단·3D + 포장 기본값·표준횡단 반영
2026-08-28 사용자 지시 4건.

① 물넘이 파임을 3D 코리도에도 반영
   classifyStation 이 노면·노견 조각 표고를 횡단도와 같은 산식(fordDeckElevationAt)
   으로 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다. BUILD_VERSION 85.

② 표준횡단면 저장분을 포장 재계산에 싣는다
   확정 때 종단 정본 options 에 실린 standard_cross_section 을 읽어
   enforce_pavement_ranges / attach_default_designs 에 넘긴다. 없으면 config 기본값.

③ 포장 구간 기본값 10 / 5 / 5 (기슭막이와 같은 출발값).

④ 독립 기슭막이
   - 측점: is_station_planting_type() 규칙 신설(A군 + D군 구간형). 구간형은
     시작·기준·종료에 측점을 심는다 — 길이가 길수록 횡단도가 여러 장 나온다.
     구조물 재이관 차단도 같은 규칙을 본다(기타 고스트 방지).
   - 서버: B06_Section_Engine_Revetment 신설 — 구간 안 측점 전부에 section.revetment.
   - 횡단도: B06_Section_UI_Cross_Revetment 신설 — 벽 상단 = 성토면 끝(설계선-지반
     교차점), 아래로 높이 + 근입 0.5m, 전면 1:0.3(교본 7-3). 설치 측은 사용자가
     좌/우로 고른다(레지스트리 side 옵션 신설 — 자동 판정 없음).
   - 3D: 같은 폴리곤을 기준측점 전/후만큼 스윕. BUILD_VERSION 86 + 구조물 해시에
     물넘이·기슭막이 제원 추가.

검증: pytest 241 passed / 7 skipped(신규 4건). 공용 브라우저 실측 —
물넘이 240m 투입 시 3D 노면이 계획고보다 0.38~0.42m 아래(대조군 200m 정상),
기슭막이 225~255 투입 시 측점 23→25·횡단 카드 3장·3D revet 솔리드 3개(31링).
임시 데이터는 원래 정본으로 복구했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:54:45 +09:00

325 lines
15 KiB
Python

"""B06 라우터의 횡단 설계 파일 읽기와 프리뷰 계산."""
import json
import logging
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
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 compute_cross_design
from common_util.common_util_route_profile import design_elevation_from_longitudinal
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
logger = logging.getLogger(__name__)
PREVIEW_DESIGN_FIELDS = (
"ground_type",
"roadbed_width_m",
"cut_area_m2",
"cut_soil_area_m2",
"cut_rock_area_m2",
"cut_rock_kind",
"fill_area_m2",
"fill_ground_slope",
"design_elevation_m",
)
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 포장 구간을 읽지 못했습니다 (사용자 지정 없음으로 본다)")
for chainage, spec in load_culvert_sets(project_root).items():
if spec.get("type") != "ford_pavement":
continue
half = float(spec.get("span_m") or 0.0) / 2.0
ranges.append((chainage - half, chainage + half))
return ranges
def paved_at(chainage_m: float, ranges: list[tuple[float, float]], stored: Any = None) -> bool:
"""이 측점을 포장으로 볼 것인가 — 구간 안이면 강제, 밖이면 사용자 저장값(기본 비포장)."""
for start, end in ranges:
if start - 1e-9 <= chainage_m <= end + 1e-9:
return True
return bool(stored) if isinstance(stored, bool) else False
# 사용자 조작값 — 포장 강제로 다시 계산해도 그대로 승계한다.
_USER_TOUCHED_KEYS = (
"display_half_width_m",
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"revet_link_detached",
"revet_follow_grade",
)
def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> dict[str, Any] | None:
"""확정 때 저장해 둔 표준횡단면 설정값(없으면 None = config 기본값).
사용자가 「표준 횡단면 설정」에서 고친 값은 확정 시 종단 정본 options에 실린다.
포장 강제 재계산도 같은 값을 써야 카드가 패널과 어긋나지 않는다(2026-08-28).
"""
options = (longitudinal_row or {}).get("data") or {}
options = options.get("options") if isinstance(options, dict) else None
standard = options.get("standard_cross_section") if isinstance(options, dict) else None
return standard if isinstance(standard, dict) else None
def enforce_pavement_ranges(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path,
standard: dict[str, Any] | None = None,
) -> int:
"""포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다.
사용자가 구간을 지정하면 그 안 횡단도에 **자동 반영**된다(2026-08-28 사용자 확정).
포장 여부는 횡단경사·포장층을 바꾸므로 플래그만 갈아 끼우지 않고 다시 계산한다.
사용자 조작값(반폭·구조물 조정)은 그대로 승계한다.
"""
ranges = pavement_ranges(project_root)
if not ranges:
return 0
changed = 0
for section in cross_sections:
design = section.get("design")
if not isinstance(design, dict) or design.get("paved"):
continue
chainage = float(section.get("chainage_m", 0.0))
if not paved_at(chainage, ranges):
continue
try:
recomputed = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
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=True,
standard=standard,
rock_boundary_offset_m=design.get(
"rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
),
two_stage_slope=bool(design.get("two_stage_slope", True)),
ditch_enabled=design.get("ditch_enabled"),
)
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
def default_section_modes(longitudinal: dict[str, Any]) -> dict[float, str]:
mapping: dict[float, str] = {}
stations = longitudinal.get("stations")
for station in stations if isinstance(stations, list) else []:
side = station.get("uphill_side")
if side in ("left", "right"):
mapping[round(float(station.get("chainage_m", 0.0)), 3)] = f"{side}_cut"
return mapping
def read_cross_design_inputs(
project_root: Path, longitudinal_file_path: str, chainage_m: float
) -> tuple[list[dict], float | None, bool]:
root = project_root.resolve()
longitudinal_path = (root / longitudinal_file_path).resolve()
if root not in longitudinal_path.parents:
raise ValueError("종단면 파일 경로가 프로젝트 저장소를 벗어났습니다.")
if not longitudinal_path.is_file():
raise FileNotFoundError("종단면 상세 파일을 찾을 수 없습니다.")
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
design_elevation = design_elevation_from_longitudinal(longitudinal, chainage_m)
suggested = pavement_suggestions(longitudinal).get(round(chainage_m, 3), False)
cross_dir = longitudinal_path.parent.parent / "cross_sections"
cross_path = (cross_dir / cross_filename(chainage_m)).resolve()
if cross_dir.resolve() not in cross_path.parents or not cross_path.is_file():
raise FileNotFoundError("해당 측점의 횡단 상세 파일을 찾을 수 없습니다.")
cross = json.loads(cross_path.read_text(encoding="utf-8"))
samples = cross.get("samples") if isinstance(cross, dict) else None
if not isinstance(samples, list):
raise ValueError("횡단 상세 파일 형식이 올바르지 않습니다.")
return samples, design_elevation, suggested
def attach_default_designs(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
project_root: Path | None = None,
standard: dict[str, Any] | None = None,
) -> None:
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(project_root) if project_root else []
for section in cross_sections:
if section.get("design"):
continue
try:
chainage = float(section.get("chainage_m", 0.0))
suggested = pavement.get(round(chainage, 3), False)
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type="ripping_rock",
section_mode=modes.get(round(chainage, 3), "left_cut"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage, paved_ranges),
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design.update(status="provisional", pavement_suggested=suggested)
section["design"] = design
except (ValueError, KeyError):
continue
def recompute_designs_for_alignment(
longitudinal: dict[str, Any],
cross_sections: list[dict[str, Any]],
stored_designs: list[dict[str, Any]],
standard: dict[str, Any] | None,
rock_boundary_offsets: dict[str, float] | None = None,
project_root: Path | None = None,
) -> None:
modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(project_root) if project_root else []
stored_by_chainage = {
round(float(record["chainage_m"]), 3): (record.get("design") or {})
for record in stored_designs
}
session_offsets: dict[float, float] = {}
for raw_key, offset in (rock_boundary_offsets or {}).items():
try:
session_offsets[round(float(raw_key), 3)] = float(offset)
except (TypeError, ValueError):
continue
for section in cross_sections:
chainage = float(section.get("chainage_m", 0.0))
key = round(chainage, 3)
stored = stored_by_chainage.get(key) or {}
suggested = pavement.get(key, False)
try:
design = compute_cross_design(
section.get("samples", []),
design_elevation_from_longitudinal(longitudinal, chainage),
ground_type=str(stored.get("ground_type") or "ripping_rock"),
section_mode=str(stored.get("section_mode") or modes.get(key, "left_cut")),
ditch_side=stored.get("ditch_side"),
ditch_type=str(stored.get("ditch_type") or "standard"),
paved=paved_at(chainage, paved_ranges, stored.get("paved")),
standard=standard,
rock_boundary_offset_m=session_offsets.get(
key,
stored.get("rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M),
),
two_stage_slope=bool(stored.get("two_stage_slope", True)),
ditch_enabled=stored.get("ditch_enabled"),
)
except (ValueError, KeyError):
continue
design.update(status="provisional", pavement_suggested=suggested)
if stored.get("display_half_width_m") is not None:
design["display_half_width_m"] = stored["display_half_width_m"]
if stored.get("inlet_structure") is not None:
design["inlet_structure"] = stored["inlet_structure"]
if stored.get("basin_adjust") is not None:
design["basin_adjust"] = stored["basin_adjust"]
if stored.get("ford_adjust") is not None:
design["ford_adjust"] = stored["ford_adjust"]
if stored.get("box_adjust") is not None:
design["box_adjust"] = stored["box_adjust"]
section["design"] = design
def compute_default_designs(
project_root: Path,
longitudinal_file_path: str,
chainages: list[float],
standard: dict[str, Any] | None = None,
) -> list[tuple[float, dict[str, Any]]]:
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다.
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
"""
root = project_root.resolve()
longitudinal_path = (root / longitudinal_file_path).resolve()
if root not in longitudinal_path.parents or not longitudinal_path.is_file():
return []
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
cross_dir = longitudinal_path.parent.parent / "cross_sections"
default_modes = default_section_modes(longitudinal)
pavement = pavement_suggestions(longitudinal)
paved_ranges = pavement_ranges(root)
results: list[tuple[float, dict[str, Any]]] = []
for chainage_m in chainages:
cross_path = cross_dir / cross_filename(chainage_m)
if not cross_path.is_file():
continue
try:
cross = json.loads(cross_path.read_text(encoding="utf-8"))
samples = cross.get("samples")
if not isinstance(samples, list):
continue
suggested = pavement.get(round(chainage_m, 3), False)
design = compute_cross_design(
samples,
design_elevation_from_longitudinal(longitudinal, chainage_m),
ground_type="ripping_rock",
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
paved=paved_at(chainage_m, paved_ranges),
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
design["status"] = "provisional"
design["pavement_suggested"] = suggested
results.append((chainage_m, design))
except (ValueError, KeyError, OSError, json.JSONDecodeError):
continue
return results