feat(B05): 배수 추천 구조물·관경 자동 설정 + 물넘이·세월교 개략 단면

지시 2 — 배수유역도가 유효직경 다음에 추천을 보여 주고, 초기 전처리 계산이
그 값으로 관 지점을 설정한다:
- recommend_structure(): 유효직경 임계로 구조물·관경을 고른다. D≤1500 배관(규격
  스냅 800·1000·1200·1500), 1500<D≤2000 BOX암거 후보, D>2000 세월교 검토.
  판정 근거는 유량뿐 — 계곡 횡단경사·하천 차수는 지형 계산이 필요해 뺐고 화면이
  "현장 확인"으로 안내한다(2026-08-17 사용자 확정)
- WatershedBasin에 recommended_facility·recommended_diameter_mm·required_area_m2
  추가, 응답 payload와 DetailBasin 타입까지 배선
- _apply_recommendations(): 자동 배치 관의 **빈칸에만** 추천을 채운다. 사용자가
  놓은 관(source=user)·이미 바꾼 시설·이미 있는 옵션 키는 건드리지 않는다.
  carry_facility_attributes 뒤에 얹어 승계값이 덮이지 않게 했다
- 유역 목록 표기: `Ø1234mm → 추천 Ø1500 배관` / `→ BOX암거 후보`
- 추천 관경 목록은 레지스트리 pipe_diameter_mm 선택지와 같아야 한다 — 폼에서 못
  고르는 값을 추천하지 않도록 테스트로 묶었다

지시 1 — 물넘이포장·세월교는 지정할 옵션이 거의 없으니 계산값이라도 보여 준다:
- 레지스트리에 ford_width_m(월류 폭) 신설. 기본값 없음 — 지식DB에 폭 근거가 없어
  프로그램이 지어내지 않는다
- fordSection(): 설계유량과 폭으로 필요 수심을 되짚는다. 광폭 근사로 출발해 실제
  동수반경(R=A/P)으로 수렴시킨다 — 근사값을 그대로 쓰면 통수능이 늘 설계유량에
  못 미쳐 판정이 무의미해진다. n=0.017·S=10%는 실무 수리계산서 역산값
  (울진1 물넘이·관 시트가 둘 다 S=0.10에서 검산 일치)
- 폼에 월류 폭 한 칸 + 읽기전용 요약(설계유량·필요 수심·필요 단면·유속). 설계유량은
  유역 payload에서 폼까지 배선(onPipesChanged → Page → setFacility)

⚠ 실무 시트의 수심 0.27m는 Qd 역산값이 아니라 가정 수심이다(B=20·h=0.27의 통수능
41.2㎥/s ≫ 그 시트 Qd 2.54㎥/s). 실무는 단면을 가정해 통수능을 검증하고, 프로그램은
필요 최소 수심을 낸다 — 화면 문구도 "필요 수심"으로 적었다.

검증: pytest tmp/tests 124 passed·7 skipped(신규 17건), npm run typecheck 무오류,
npm run build 성공. 물넘이 산식은 프론트 전용이라 수치 손검증으로 대신했다 —
Qd=2.54·B=20에서 필요 수심 0.0503m·유속 2.526m/s, 통수능이 2.5400㎥/s로 Qd와 일치.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:18:28 +09:00
co-authored by Claude Opus 5
parent d8aa525bb8
commit a9c46b9711
14 changed files with 263 additions and 7 deletions
@@ -480,6 +480,12 @@ export interface DetailBasin {
design_flow_m3s?: number | null;
/** 유효직경이 관 최대 규격 초과 — 세월교·물넘이·교량 검토 대상(임도설치규정 제12조). */
bridge_required?: boolean;
/** 필요 통수단면적(㎡) = 설계유량 / 유속. 물넘이·세월교 개략 단면의 출발값. */
required_area_m2?: number | null;
/** 유량 근거 추천 구조물(2026-08-17 사용자 확정) — pipe/box_culvert/ford_bridge. */
recommended_facility?: PipeFacility;
/** 추천 관경(㎜) — 배관일 때만. 레지스트리 선택지로 스냅한 값이다. */
recommended_diameter_mm?: number | null;
}
export interface DetailBasinResponse {
+43 -1
View File
@@ -26,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from common_util.common_util_drainage_context import DrainageContext, load_drainage_context
from common_util.common_util_drainage_detail import DrainageDetail, build_detail
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_PIPE,
PIPE_SOURCE_USER,
PipePoint,
carry_facility_attributes,
@@ -64,7 +65,10 @@ def _build(
rebuilt = [PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes]
# 계산기는 chainage만 다뤄 시설 종류·구간·옵션이 사라진다 — 원본에서 되붙인다
# (2026-08-17 계곡 통과 시설: 배관/BOX암거/물넘이/세월교).
return detail, carry_facility_attributes(rebuilt, points or [])
carried = carry_facility_attributes(rebuilt, points or [])
# 되붙인 뒤에 추천을 얹는다 — 순서가 뒤바뀌면 승계된 사용자 값이 추천에 덮인다.
_apply_recommendations(detail, carried)
return detail, carried
def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
@@ -78,6 +82,39 @@ def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None:
pipe.reason = source or PIPE_SOURCE_USER
def _apply_recommendations(detail: DrainageDetail, points: list[PipePoint]) -> None:
"""유역별 추천(시설 종류·관경)을 관 지점 옵션의 **빈칸에만** 채운다.
"초기 전처리 계산에서 해당값으로 설정"(2026-08-17 사용자 지시) — 자동 배치된 관이
유역 유량에 맞는 규격을 처음부터 갖고 있어야 폼을 열지 않아도 하류가 값을 받는다.
덮어쓰지 않는 두 경우: ① 사용자가 직접 놓거나 옮긴 관(`source == "user"`) ②
그 옵션 키가 이미 있는 관. 설계자가 고른 값을 재계산이 되돌리면 안 되기 때문이다.
"""
by_chainage = {round(basin.chainage_m, 2): basin for basin in detail.basins}
for point in points:
if point.source == PIPE_SOURCE_USER:
continue
basin = by_chainage.get(round(point.chainage_m, 2))
if basin is None:
continue
options = dict(point.options or {})
# 시설 종류는 아직 손대지 않은 기본 배관일 때만 추천으로 바꾼다.
if (
point.facility == PIPE_FACILITY_PIPE
and basin.recommended_facility != PIPE_FACILITY_PIPE
):
point.facility = basin.recommended_facility
if (
point.facility == PIPE_FACILITY_PIPE
and basin.recommended_diameter_mm is not None
and "pipe_diameter_mm" not in options
):
options["pipe_diameter_mm"] = basin.recommended_diameter_mm
if options:
point.options = options
def _payload(
project_id: UUID,
context: DrainageContext,
@@ -136,6 +173,11 @@ def _payload(
"design_flow_m3s": basin.design_flow_m3s,
# 유효직경이 관 최대 규격 초과 — 세월교·물넘이·교량 검토 대상(임도설치규정 제12조).
"bridge_required": basin.bridge_required,
# 필요 통수단면적(㎡) — 물넘이·세월교 개략 단면의 출발값.
"required_area_m2": basin.required_area_m2,
# 유량 근거 추천(2026-08-17 사용자 확정) — 배관이면 규격 스냅 관경이 붙는다.
"recommended_facility": basin.recommended_facility,
"recommended_diameter_mm": basin.recommended_diameter_mm,
}
for basin in detail.basins
],
+20 -1
View File
@@ -276,7 +276,17 @@
"abbr": "물넘이"
},
"drawing_views": ["plan", "profile", "cross_section", "detail", "quantity"],
"options": []
"options": [
{
"key": "ford_width_m",
"label": "월류 폭",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
}
]
},
{
"type_id": "ford_bridge",
@@ -314,6 +324,15 @@
"unit": "련",
"default": null,
"required": false
},
{
"key": "ford_width_m",
"label": "월류 폭",
"input": "number",
"unit": "m",
"default": null,
"required": true,
"phase": "detail"
}
]
},
@@ -37,6 +37,7 @@ import {
BOX_SIZE_PRESETS,
createRevetmentFields,
createWingFields,
fordSection,
grid,
group,
INLET_REVET_KEYS,
@@ -141,6 +142,8 @@ export interface FacilityOptionsForm {
setFacility: (
facility: PipeFacility | "revetment" | null,
options?: Record<string, string | number>,
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. 없으면 안내만. */
designFlow?: number | null,
) => void;
/** 현재 필드 값을 시설 옵션으로 읽는다(빈 값 생략). */
readOptions: () => Record<string, string | number>;
@@ -256,6 +259,33 @@ export function createFacilityOptionsForm(
const fordCount = numberInput("1", "1", "련");
const fordRow = grid(labeled("수량 (련)", fordCount));
// ── 물넘이·세월교 개략 단면 — 지정할 옵션이 거의 없으니 계산값이라도 보여 준다
// (2026-08-17 사용자 지시). 폭만 받고 설계유량으로 수심·통수능을 되짚는다.
const fordWidth = numberInput("0.1");
const fordWidthRow = grid(labeled("월류 폭 (m)", fordWidth));
const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
let designFlowM3s: number | null = null;
function syncFordSummary(): void {
if (designFlowM3s === null) {
fordSummary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const section = fordSection(designFlowM3s, Number.parseFloat(fordWidth.value));
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 실제 설계 수심은 여기에 여유를 더해 정한다.
fordSummary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s`;
}
root.append(
pipeRow,
inletGroup.root,
@@ -264,7 +294,9 @@ export function createFacilityOptionsForm(
boxWrap,
wingInFields.root,
wingOutFields.root,
fordWidthRow,
fordRow,
fordSummary,
);
let current: PipeFacility | "revetment" | null = null;
@@ -283,7 +315,11 @@ export function createFacilityOptionsForm(
boxWrap.hidden = !isBox;
wingInFields.root.hidden = !isBox;
wingOutFields.root.hidden = !isBox;
const isFord = current === "ford_pavement" || current === "ford_bridge";
fordRow.hidden = current !== "ford_bridge";
fordWidthRow.hidden = !isFord;
fordSummary.hidden = !isFord;
if (isFord) syncFordSummary();
if (isBox) {
syncBoxSize();
wingInFields.syncVisibility();
@@ -321,12 +357,14 @@ export function createFacilityOptionsForm(
...wingInFields.inputs,
...wingOutFields.inputs,
fordCount,
fordWidth,
].forEach((input) => input.addEventListener("change", emit));
return {
root,
setFacility(facility, options = {}) {
setFacility(facility, options = {}, designFlow = null) {
current = facility;
designFlowM3s = designFlow ?? null;
if (facility === null) {
syncVisibility();
return;
@@ -365,6 +403,7 @@ export function createFacilityOptionsForm(
wingInFields.write(options);
wingOutFields.write(options);
fordCount.value = isFord ? text("pipe_count") : "";
fordWidth.value = text("ford_width_m");
syncVisibility();
},
readOptions() {
@@ -389,11 +428,14 @@ export function createFacilityOptionsForm(
putNumber(options, "body_height_m", boxHeight.value);
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "ford_pavement") {
putNumber(options, "ford_width_m", fordWidth.value);
} else if (current === "ford_bridge") {
options.pipe_kind = pipeMaterial.value;
options.pipe_diameter_mm = Number(pipeDiameter.value);
const count = Number.parseInt(fordCount.value, 10);
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
putNumber(options, "ford_width_m", fordWidth.value);
}
return options;
},
@@ -8,6 +8,50 @@
* 유입·유출·독립 기슭막이(D4)가 같은 조각을 키만 바꿔 쓰는 구조도 그대로다.
* ========================================================================== */
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
/** 월류 단면 한 벌 — 설계유량을 흘리는 데 **필요한** 수심·단면적과 그때의 유속. */
export interface FordSection {
depthM: number;
areaM2: number;
velocityMs: number;
}
/** 물넘이(노면 개수로)의 개략 단면 — 설계유량과 월류 폭으로 필요 수심을 되짚는다.
*
* 광폭 직사각형(B ≫ h)으로 보면 Manning을 h에 대해 바로 풀 수 있다:
* Qd = B·h·(1/n)·h^(2/3)·√S → h = (Qd·n / (B·√S))^(3/5)
* 다만 실제 동수반경은 R = A/P (P = B + 2h)로 h보다 작아, 광폭 근사값을 그대로 쓰면
* 통수능이 설계유량에 조금 못 미친다. 그래서 그 값을 출발점 삼아 실제 R로 몇 번
* 되짚어 **B·h·V(h) = Qd**가 되는 h로 수렴시킨다.
*
* n·S는 실무 수리계산서 역산값이다(`config_frontend.FORD_MANNING_N`·`FORD_SLOPE`,
* 2026-08-17 확인). 폭 B는 지식DB에 수치 근거가 없어 사용자가 넣는다 — 프로그램이
* 지어내지 않는다. 유량이나 폭이 없으면 null(표시할 값 없음)이다.
*
* ⚠ 여기서 내는 수심은 **필요 최소 수심**이다. 실무 수리계산서가 적는 수심은 설계자가
* 가정한 값이고(울진1: B=20m·h=0.27m → 통수능 41.2㎥/s ≫ Qd 2.54㎥/s), 그 시트는
* 가정 단면의 통수능이 설계유량을 넘는지 검증하는 방식이다. 둘은 의미가 다르다. */
export function fordSection(designFlowM3s: number | null, widthM: number): FordSection | null {
if (!designFlowM3s || designFlowM3s <= 0 || !Number.isFinite(widthM) || widthM <= 0) return null;
const slopeRoot = Math.sqrt(FORD_SLOPE);
const velocityAt = (depth: number): number =>
(1 / FORD_MANNING_N) * Math.pow((widthM * depth) / (widthM + 2 * depth), 2 / 3) * slopeRoot;
// 광폭 근사 출발값 — 실제 R보다 크게 잡히므로 아래 반복이 조금씩 키워 수렴한다.
let depth = Math.pow((designFlowM3s * FORD_MANNING_N) / (widthM * slopeRoot), 3 / 5);
for (let index = 0; index < 20; index += 1) {
const next = designFlowM3s / (widthM * velocityAt(depth));
if (Math.abs(next - depth) < 1e-6) {
depth = next;
break;
}
depth = next;
}
return { depthM: depth, areaM2: widthM * depth, velocityMs: velocityAt(depth) };
}
/* ── 폼 조각 ────────────────────────────────────────────────────────────── */
export function labeled(text: string, input: HTMLElement): HTMLLabelElement {
@@ -102,6 +102,8 @@ export interface DrainagePanelCallbacks {
pipes: Array<{
chainage_m: number;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */
design_flow_m3s?: number | null;
facility: PipeFacility;
start_m?: number;
end_m?: number;
@@ -400,6 +402,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
chainage_m: chainage,
effective_diameter_mm:
basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null,
// 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다.
design_flow_m3s: basin?.design_flow_m3s ?? null,
facility: attributes?.facility ?? "pipe",
start_m: attributes?.start_m,
end_m: attributes?.end_m,
+13 -1
View File
@@ -111,6 +111,14 @@ export function addLayerToggle(
}
/** 유역 제원 목록을 다시 그린다. 항목을 누르면 `onPick`으로 번호를 돌려준다. */
/** 유역 추천을 한 조각 문구로 — 배관이면 규격 스냅 관경, BOX암거면 후보 표기.
* 추천이 아직 없는(강우량표 전) 유역은 계산 유효직경만 남긴다. */
function recommendationOf(basin: DetailBasin): string {
if (basin.recommended_facility === "box_culvert") return L("B05_Drainage_Basin_RecBox");
if (basin.recommended_diameter_mm == null) return L("B05_Drainage_Basin_Undecided");
return L("B05_Drainage_Basin_RecPipe").replace("{d}", String(basin.recommended_diameter_mm));
}
export function renderBasinRows(
container: HTMLElement,
basins: ReadonlyArray<DetailBasin>,
@@ -131,6 +139,8 @@ export function renderBasinRows(
metrics.className = "b05-drainage__basin-metrics";
// 유효직경은 강우량표(rainfall_table.json)가 생기기 전까지 null → "미정" 표기.
// 관 최대 규격 초과 계류 유역은 관이 아니라 세월교 대상 — "세월교 검토"로 표기한다.
// 그 아래 유역은 계산 유효직경 뒤에 추천(규격 스냅 관경 또는 BOX암거)을 덧붙인다
// (2026-08-17 사용자 지시 — 추천 근거는 유량뿐, 지형 조건은 툴팁으로 안내).
const pipe = basin.bridge_required
? L("B05_Drainage_Basin_Bridge").replace(
"{d}",
@@ -138,7 +148,7 @@ export function renderBasinRows(
)
: basin.pipe_diameter_mm === null
? L("B05_Drainage_Basin_Undecided")
: `Ø${Math.round(basin.pipe_diameter_mm)}mm`;
: `Ø${Math.round(basin.pipe_diameter_mm)}mm → ${recommendationOf(basin)}`;
metrics.textContent = L("B05_Drainage_Basin_Metrics")
.replace("{area}", formatArea(basin.area_m2))
.replace("{relief}", basin.relief_m.toFixed(1))
@@ -154,6 +164,8 @@ export function renderBasinRows(
.replace("{i}", String(basin.intensity_mm_hr ?? "-"))
.replace("{q}", String(basin.design_flow_m3s));
}
// 추천 근거의 한계를 같은 툴팁에 밝힌다 — 유량만 보고 고른 값이다.
row.title += "\n" + L("B05_Drainage_Basin_RecNote");
row.append(badge, metrics);
row.addEventListener("click", () => onPick(basin.index));
container.append(row);
+1
View File
@@ -98,6 +98,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
start_m: pipe.start_m,
end_m: pipe.end_m,
source: pipe.source,
design_flow_m3s: pipe.design_flow_m3s,
options: pipe.options,
})),
);
@@ -91,6 +91,8 @@ export interface RouteProfilePanelCallbacks {
pipes: Array<{
chainage_m: number;
effective_diameter_mm: number | null;
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */
design_flow_m3s?: number | null;
facility: PipeFacility;
start_m?: number;
end_m?: number;
@@ -60,6 +60,8 @@ export interface PipeFacilityItem {
end_m?: number;
/** 자동 배치 출처(stream/spacing/user) — 배관 유형(계곡부형/보완형) 제안 근거. */
source?: PipeSource;
/** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면 계산의 입력. */
design_flow_m3s?: number | null;
/** 부속 옵션(유형·집수정·기슭막이·돌붙임·날개벽·세월교 관 등). */
options?: Record<string, string | number>;
}
@@ -246,8 +248,11 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
}
/** 종류에 맞춰 부속 옵션 서브폼을 켠다(계곡 통과 시설·독립 기슭막이, 값은 인자로). */
function syncFacilityForm(options: Record<string, string | number> = {}): void {
facilityOptions.setFacility(facilityFormKind(currentType()), options);
function syncFacilityForm(
options: Record<string, string | number> = {},
designFlow: number | null = null,
): void {
facilityOptions.setFacility(facilityFormKind(currentType()), options, designFlow);
}
/** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 상세(detail) 옵션은 B06/B07
@@ -385,7 +390,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
startFields.write(null, step);
endFields.write(null, step);
memoField.value = "";
syncFacilityForm(pipe.options ?? {});
syncFacilityForm(pipe.options ?? {}, pipe.design_flow_m3s ?? null);
[anchorFields, startFields, endFields].forEach((fields) => fields.clearInvalid());
syncPlacementFields();
syncButtons();
@@ -36,6 +36,11 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import find_inflow_h
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import STAGES
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Flow import largest_ring, polygonize_labels
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import GridSpec
from common_util.common_util_drainage_pipes import (
PIPE_FACILITY_BOX,
PIPE_FACILITY_FORD_BRIDGE,
PIPE_FACILITY_PIPE,
)
from common_util.common_util_route_geometry import (
RouteVertex,
StructureCandidate,
@@ -44,11 +49,13 @@ from common_util.common_util_route_geometry import (
)
from common_util.common_util_wamis_rainfall import idf_intensity
from config.config_system import (
DRAINAGE_BOX_THRESHOLD_MM,
DRAINAGE_BRIDGE_THRESHOLD_MM,
DRAINAGE_DESIGN_FLOW_FACTOR,
DRAINAGE_DITCH_SAMPLE_M,
DRAINAGE_FLOW_AREA_RATIO,
DRAINAGE_MANNING_N,
DRAINAGE_RECOMMEND_DIAMETERS_MM,
DRAINAGE_PIPE_MAX_SPACING_M,
DRAINAGE_PIPE_MIN_SPACING_M,
DRAINAGE_PIPE_SLOPE_DEG,
@@ -57,6 +64,7 @@ from config.config_system import (
DRAINAGE_TC_MIN_MINUTES,
DRAINAGE_VELOCITY_MAX_MS,
DRAINAGE_VELOCITY_MIN_MS,
PIPE_DEFAULT_DIAMETER_MM,
)
logger = logging.getLogger(__name__)
@@ -108,6 +116,12 @@ class WatershedBasin:
# 유효직경이 관 최대 규격을 넘는 계류 유역 — 관이 아니라 세월교·물넘이·교량 대상
# (임도설치규정 제12조). 화면은 "세월교 검토"로 표기하고 관경 자동 지정을 하지 않는다.
bridge_required: bool = False
# 필요 통수단면적(㎡) = 설계유량 / 유속. 물넘이·세월교 개략 단면의 출발값이다.
required_area_m2: float | None = None
# 유효직경으로 고른 추천 구조물·관경 (2026-08-17 사용자 확정: 유량 근거만).
# 배관이면 레지스트리 선택지로 스냅한 관경이 붙고, BOX암거·세월교면 None이다.
recommended_facility: str = "pipe"
recommended_diameter_mm: int | None = None
@dataclass
@@ -519,6 +533,7 @@ def assemble_basins(
relief = max(0.0, highest - outlet_z)
flow_length = float(routing.path_length[member].max())
sizing = size_pipe(area, relief, flow_length, idf)
facility, recommended = recommend_structure(sizing["diameter_mm"] if sizing else None)
basins.append(
WatershedBasin(
index=len(basins) + 1,
@@ -536,6 +551,9 @@ def assemble_basins(
bridge_required=bool(
sizing and sizing["diameter_mm"] > DRAINAGE_BRIDGE_THRESHOLD_MM
),
required_area_m2=sizing["required_area_m2"] if sizing else None,
recommended_facility=facility,
recommended_diameter_mm=recommended,
)
)
return basins
@@ -617,9 +635,38 @@ def size_pipe(
"intensity_mm_hr": round(intensity, 1),
"design_flow_m3s": round(design_flow, 4),
"diameter_mm": round(diameter * 1000.0, 1),
# 물넘이·세월교는 관이 아니라 개수로라 직경 대신 이 단면적에서 출발한다.
"required_area_m2": round(required_area, 4),
}
def recommend_structure(diameter_mm: float | None) -> tuple[str, int | None]:
"""유효직경으로 추천 구조물과 관경을 고른다 (2026-08-17 사용자 확정: 유량 근거만).
지식DB 선정 트리(개거_세월시설 §5·횡단배수관_암거 §5)에서 유량 조건만 취했다
계곡 횡단경사·하천 차수는 지형 계산이 필요해 화면이 "현장 확인"으로 안내한다.
D 1,500 배관 (레지스트리 선택지로 스냅, 하한 800)
1,500 < D 2,000 BOX암거 후보
D > 2,000 세월교·물넘이 검토 ( 최대 규격 초과)
유효직경을 구한 유역(강우량표 없음) 배관·관경 미정으로 둔다.
"""
if diameter_mm is None:
return PIPE_FACILITY_PIPE, None
if diameter_mm > DRAINAGE_BRIDGE_THRESHOLD_MM:
return PIPE_FACILITY_FORD_BRIDGE, None
if diameter_mm > DRAINAGE_BOX_THRESHOLD_MM:
return PIPE_FACILITY_BOX, None
# 별표2 (나) 예외 하한 800㎜ — 계산값이 더 작아도 그 아래로는 내리지 않는다.
need = max(float(PIPE_DEFAULT_DIAMETER_MM), diameter_mm)
size = next(
(value for value in DRAINAGE_RECOMMEND_DIAMETERS_MM if value >= need),
DRAINAGE_RECOMMEND_DIAMETERS_MM[-1],
)
return PIPE_FACILITY_PIPE, int(size)
def estimate_pipe_diameter_mm(
area_m2: float,
relief_m: float,
+7
View File
@@ -147,6 +147,13 @@ export const PIPE_DEFAULT_TYPE = "파형강관";
/** 자동 지정 기본 관경 — 별표2 (나) 예외 하한 800mm. 유효직경이 더 크면 바로 위 규격. */
export const PIPE_DEFAULT_DIAMETER_MM = 800;
/** KDS 2.7-1 "" .
* `config_system.FORD_MANNING_N` . */
export const FORD_MANNING_N = 0.017;
/** 10%(2026-08-17 ).
* `config_system.FORD_SLOPE` . */
export const FORD_SLOPE = 0.1;
export const ESCAPE_ROUTE_WIDTHS_M = [1.5, 2.0, 2.5, 3.0] as const;
export const ESCAPE_ROUTE_DEFAULT_WIDTH_M = 2.0;
export const STRUCTURE_ETC_DEFAULT_NAME = "기타 구조물";
+16
View File
@@ -385,6 +385,22 @@ DRAINAGE_FLOW_AREA_RATIO = float(os.getenv("DRAINAGE_FLOW_AREA_RATIO", "0.7"))
# 세월교·물넘이·교량 대상이다 — 「임도설치 및 관리 등에 관한 규정」 제12조: 계류 횡단
# 구간은 배수구 막힘 우려가 없는 물넘이 포장(세월교) 또는 교량으로 설계(2026-08-05 조사).
DRAINAGE_BRIDGE_THRESHOLD_MM = float(os.getenv("DRAINAGE_BRIDGE_THRESHOLD_MM", "2000"))
# BOX암거 전환 문턱(mm). 교본 "수리계산 Ø1,500㎜ 이상 유역·협곡·횡단경사 40% 이내"에서
# 유량 조건만 취한 값이다 — 횡단경사·협곡 판정은 지형 계산이 필요해 화면이 "현장 확인"으로
# 안내한다 (2026-08-17 사용자 확정: 추천은 유량 근거만).
DRAINAGE_BOX_THRESHOLD_MM = float(os.getenv("DRAINAGE_BOX_THRESHOLD_MM", "1500"))
# 추천 관경 규격(mm) — B05 레지스트리 `pipe_diameter_mm` 선택지와 **같아야 한다**.
# 폼에서 고를 수 없는 값을 추천하면 사용자가 그대로 확정하지 못한다.
DRAINAGE_RECOMMEND_DIAMETERS_MM = (800, 1000, 1200, 1500)
# ── 물넘이포장·세월교 개략 단면 (2026-08-17 실무문서 역산 확인) ──
# 물넘이는 노면 개수로다. 조도계수 0.017은 KDS 표 2.7-1 콘크리트 수로 "보통"값이자
# 실무 물넘이 관측치와 일치하고, 경사 10%는 실무 수리계산서 역산값이다 — 울진1공구
# 물넘이 시트(B=20m·h=0.27m·n=0.017 → V=7.636 m/s)와 같은 공사지 관 시트(Ø1000·240°
# 유효단면·n=0.025 → V=5.694 m/s)가 둘 다 S=0.10에서 검산이 맞는다
# (original/실무문서/_숨김탭분석.md §6-1·§6-2).
FORD_MANNING_N = float(os.getenv("FORD_MANNING_N", "0.017"))
FORD_SLOPE = float(os.getenv("FORD_SLOPE", "0.10"))
# ── B05 구조물 옵션 (드롭다운 목록·기본값, 2026-08-05 사용자 확정) ──
# 프론트는 이 값을 /api 설정 응답 또는 빌드타임 복사로 받아 쓴다. 수정은 여기서만 한다.
+9
View File
@@ -91,6 +91,15 @@ export const ui_locales_b2 = {
],
/* {chainage}=측점 누가거리(m) */
B05_Drainage_Basin_Chainage: ["측점 누가거리 {chainage}m", "Station chainage {chainage}m"],
/* {d}=규격 스냅 관경(mm). 유효직경 이상인 가장 작은 레지스트리 선택지 */
B05_Drainage_Basin_RecPipe: ["추천 Ø{d} 배관", "Rec. Ø{d} pipe"],
/* 유효직경 Ø1,500 초과 — 교본 BOX암거 전환 유량 조건 */
B05_Drainage_Basin_RecBox: ["BOX암거 후보", "Box culvert candidate"],
/* 추천은 유량 근거만 — 횡단경사·차수 조건은 화면이 판정하지 않는다 */
B05_Drainage_Basin_RecNote: [
"추천은 유량(유효직경) 근거만 반영합니다 — 계곡 횡단경사·하천 차수는 현장 확인.",
"Recommendation uses discharge only — check valley cross slope and stream order on site.",
],
/* --- B05_Profile 경로 설계 --- */
B05_Route_Title: ["종단설계", "Profile Design"],