feat(B06): 배수관 세트 제원 TS 짝 신설 — 브라우저에서도 계산 가능
B05·B06 계산을 서버·브라우저 양쪽에서 돌 수 있게 하는 작업의 1단계. 배수관 세트(배관·세월교·BOX암거·물넘이포장·독립 기슭막이) 제원이 서버에만 있어 브라우저는 스펙 칸을 하나씩 옮겨 적는 부분 사본으로 버티고 있었다. - common_util_culvert_sets.ts 신설 — B06_Section_Engine_Culvert.py 의 짝. 매 상세 조회마다 도는 자리라 Node 왕복 대신 짝을 택함(CLAUDE.md 5장 ①). - 거울 테스트 추가 — 다섯 시설을 태워 측점별 딕셔너리째 대조. - 흩어진 임시 산식 3곳(관경÷1000 · BOX 구체 길이 · 날개벽 바닥판 연장)을 이 한 벌로 바꿈. 검증: tsc --noEmit 통과, pytest 387 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
/* =============================================================================
|
||||
* common_util_culvert_sets.ts
|
||||
* 배수관 세트(배관·기슭막이·보호공·세월교·BOX암거·물넘이포장) 제원 — **브라우저 몫**.
|
||||
*
|
||||
* ⚠ 파이썬 `B06_Section/B06_Section_Engine_Culvert.py` 와 **한 벌**이다(2026-09-06).
|
||||
* 한쪽만 고치면 화면과 저장본이 갈린다. 거울 테스트: `tmp/tests/test_b06_culvert_sets_mirror.py`
|
||||
*
|
||||
* 왜 짝으로 두나(CLAUDE.md 5장 「계산 자리」) — 이 계산은 종횡단 **상세를 읽을 때마다**
|
||||
* 서버에서 돈다. 매 요청 도는 자리라 Node 왕복(코리도·구조물 면적 방식)이 비싸다.
|
||||
* 그래서 세 갈래 중 ①(파이썬·TS 짝 + 거울 테스트)을 골랐다.
|
||||
*
|
||||
* 값을 새로 만들지 않는다 — **정본(관 지점 옵션) + 레지스트리 기본값**만 조합한다.
|
||||
* 상수 사본을 두면 B05 폼과 갈라지므로 기본값은 레지스트리에서만 꺼낸다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 관 지점 정본 1건 — `pipe_points.json` 의 한 줄(브라우저는 API 로 같은 것을 받는다). */
|
||||
export interface CulvertPipePoint {
|
||||
chainage_m: number;
|
||||
facility?: string;
|
||||
options?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** 레지스트리 타입별 옵션 기본값 — `type_id → { 옵션키: 기본값 }`. */
|
||||
export type CulvertRegistryDefaults = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** 세트 제원 — 파이썬이 내는 dict 와 같은 모양이라 키를 그대로 쓴다. */
|
||||
export type CulvertSetSpec = Record<string, unknown>;
|
||||
|
||||
/* ── 파이썬 짝과 같은 상수 (근거 주석은 파이썬 쪽에 있다) ───────────────── */
|
||||
const CHAINAGE_TOLERANCE_M = 0.02;
|
||||
const SECTION_KEYS: Record<string, string> = {
|
||||
ford: "ford",
|
||||
box: "box",
|
||||
ford_pavement: "ford_pavement",
|
||||
};
|
||||
/** 폭 절반만큼 옆 측점에도 걸치는 종류 — 물넘이포장만. */
|
||||
const SPAN_LINKED_TYPES = new Set(["ford_pavement"]);
|
||||
|
||||
export const MIN_PIPE_COVER_M = 0.5;
|
||||
export const APRON_LENGTH_FACTOR = 2.0;
|
||||
export const APRON_THICKNESS_M = 0.45;
|
||||
export const REVET_FACE_SLOPE = 0.3;
|
||||
export const INLET_STRUCTURE_BASIN = "집수정";
|
||||
export const FORD_SLAB_THICKNESS_M = 0.3;
|
||||
export const FORD_WALL_THICKNESS_M = 0.2;
|
||||
export const FORD_DEFAULT_WIDTH_M = 10.0;
|
||||
export const FORD_PAVEMENT_DEFAULT_WIDTH_M = 5.0;
|
||||
export const BOX_COVER_M = 0.5;
|
||||
|
||||
/** 시설 종류 — 파이썬 `common_util_drainage_pipes` 의 상수와 같은 문자열. */
|
||||
const FACILITY_PIPE = "pipe";
|
||||
const FACILITY_BOX = "box_culvert";
|
||||
const FACILITY_FORD_PAVEMENT = "ford_pavement";
|
||||
const FACILITY_FORD_BRIDGE = "ford_bridge";
|
||||
const FACILITY_REVET = "revetment";
|
||||
|
||||
/** 숫자 옵션 하나를 정리한다. 문자열 저장분(관경 "1000")도 받는다 — 파이썬 `_number`. */
|
||||
function num(value: unknown, fallback: number | null): number | null {
|
||||
if (typeof value === "boolean") return fallback;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : fallback;
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value.trim());
|
||||
return value.trim() !== "" && Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** 파이썬 `round(x, n)` 자리에 쓰는 반올림. */
|
||||
function round(value: number, digits: number): number {
|
||||
return Number(value.toFixed(digits));
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return value === null || value === undefined || value === "" ? null : String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유입("inlet")·유출("outlet") 한쪽의 부속 제원.
|
||||
* 구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 화면은 라벨만 쓴다.
|
||||
*/
|
||||
function sideSpec(
|
||||
options: Record<string, unknown>,
|
||||
defaults: Record<string, unknown>,
|
||||
side: string,
|
||||
): CulvertSetSpec {
|
||||
const structure = text(options[`${side}_type`]) ?? text(defaults[`${side}_type`]) ?? "기슭막이";
|
||||
const spec: CulvertSetSpec = { role: side, structure };
|
||||
if (structure === INLET_STRUCTURE_BASIN) {
|
||||
spec.basin_length_m = num(
|
||||
options.inlet_basin_length_m,
|
||||
num(defaults.inlet_basin_length_m, 2.0),
|
||||
);
|
||||
spec.basin_before_m = num(
|
||||
options.inlet_basin_before_m,
|
||||
num(defaults.inlet_basin_before_m, null),
|
||||
);
|
||||
spec.basin_after_m = num(options.inlet_basin_after_m, num(defaults.inlet_basin_after_m, null));
|
||||
return spec;
|
||||
}
|
||||
|
||||
const height = num(
|
||||
options[`${side}_revet_height_m`],
|
||||
num(defaults[`${side}_revet_height_m`], null),
|
||||
);
|
||||
const length = num(
|
||||
options[`${side}_revet_length_m`],
|
||||
num(defaults[`${side}_revet_length_m`], null),
|
||||
);
|
||||
const form = text(options[`${side}_revet_form`]) ?? text(defaults[`${side}_revet_form`]);
|
||||
const before = num(
|
||||
options[`${side}_revet_before_m`],
|
||||
num(defaults[`${side}_revet_before_m`], null),
|
||||
);
|
||||
const after = num(options[`${side}_revet_after_m`], num(defaults[`${side}_revet_after_m`], null));
|
||||
spec.revet_form = form;
|
||||
spec.revet_height_m = height;
|
||||
spec.revet_length_m = length;
|
||||
spec.revet_before_m = before;
|
||||
spec.revet_after_m = after;
|
||||
spec.face_slope = REVET_FACE_SLOPE;
|
||||
// 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다.
|
||||
if (height !== null && height > 0) {
|
||||
spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3);
|
||||
spec.apron_thickness_m = APRON_THICKNESS_M;
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** 관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공). */
|
||||
function culvertSet(
|
||||
options: Record<string, unknown>,
|
||||
registry: CulvertRegistryDefaults,
|
||||
): CulvertSetSpec {
|
||||
const defaults = registry.pipe ?? {};
|
||||
const diameterMm = num(options.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0));
|
||||
return {
|
||||
type: "pipe",
|
||||
pipe_kind: text(options.pipe_kind) ?? text(defaults.pipe_kind),
|
||||
diameter_m: pipeDiameterM(diameterMm),
|
||||
min_cover_m: MIN_PIPE_COVER_M,
|
||||
inlet: sideSpec(options, defaults, "inlet"),
|
||||
outlet: sideSpec(options, defaults, "outlet"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 독립 기슭막이 한쪽 벽 제원 — 배관 벽과 **같은 옵션 키**(`{role}_revet_*`)를 쓴다.
|
||||
* 역할 키가 없으면 B05 폼이 한 벌로 담던 옛 키(`length_m`…)로 폴백한다.
|
||||
*/
|
||||
function revetSide(values: Record<string, unknown>, role: string): CulvertSetSpec {
|
||||
const height = num(values[`${role}_revet_height_m`], num(values.height_m, null));
|
||||
const form = text(values[`${role}_revet_form`]) ?? text(values.form);
|
||||
const spec: CulvertSetSpec = {
|
||||
role,
|
||||
structure: "기슭막이",
|
||||
revet_form: form,
|
||||
revet_height_m: height,
|
||||
revet_length_m: num(values[`${role}_revet_length_m`], num(values.length_m, null)),
|
||||
revet_before_m: num(values[`${role}_revet_before_m`], num(values.before_m, null)),
|
||||
revet_after_m: num(values[`${role}_revet_after_m`], num(values.after_m, null)),
|
||||
face_slope: REVET_FACE_SLOPE,
|
||||
};
|
||||
if (height !== null && height > 0) {
|
||||
spec.apron_length_m = round(height * APRON_LENGTH_FACTOR, 3);
|
||||
spec.apron_thickness_m = APRON_THICKNESS_M;
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** 독립 기슭막이 1개소 — 배관 세트 모양이되 **관을 숨긴다**(hidden_pipe). */
|
||||
function revetSet(values: Record<string, unknown>): CulvertSetSpec {
|
||||
return {
|
||||
type: "pipe",
|
||||
hidden_pipe: true,
|
||||
side: String(values.side ?? "양쪽"),
|
||||
tiers: Math.trunc(num(values.tiers, 1.0) || 1),
|
||||
pipe_kind: null,
|
||||
diameter_m: 0.3,
|
||||
min_cover_m: 0.0,
|
||||
inlet: revetSide(values, "inlet"),
|
||||
outlet: revetSide(values, "outlet"),
|
||||
};
|
||||
}
|
||||
|
||||
/** 관경(㎜) → 관 지름(m). 파이썬 `_culvert_set`·`_ford_set` 과 같은 반올림. */
|
||||
export function pipeDiameterM(diameterMm: number | null | undefined): number {
|
||||
return round((num(diameterMm, 1000.0) ?? 1000.0) / 1000.0, 3);
|
||||
}
|
||||
|
||||
/** BOX암거 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. */
|
||||
export function boxSpanM(innerWidthM: number): number {
|
||||
return innerWidthM + 2 * FORD_WALL_THICKNESS_M;
|
||||
}
|
||||
|
||||
/** 날개벽이 만드는 바닥판 연장량 = 길이 × cos(벌어짐각). 안 세우면 0. */
|
||||
export function wingSlabExtendM(
|
||||
installed: boolean,
|
||||
lengthM: number | null | undefined,
|
||||
angleDeg: number | null | undefined,
|
||||
): number {
|
||||
if (!installed) return 0;
|
||||
const extend = (lengthM ?? 0) * Math.cos(((angleDeg ?? 45) * Math.PI) / 180);
|
||||
return round(Math.max(extend, 0.0), 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
|
||||
* 연장량 = 길이 × cos(각도) — 각도는 관축(계류 방향) 기준 벌어짐각이다.
|
||||
*/
|
||||
function wingSpec(
|
||||
values: Record<string, unknown>,
|
||||
defaults: Record<string, unknown>,
|
||||
side: string,
|
||||
): CulvertSetSpec {
|
||||
const prefix = `wing_${side}`;
|
||||
const install = values[prefix] ?? defaults[prefix];
|
||||
const length = num(values[`${prefix}_length_m`], num(defaults[`${prefix}_length_m`], 0.0));
|
||||
const angle = num(values[`${prefix}_angle_deg`], num(defaults[`${prefix}_angle_deg`], 45.0));
|
||||
const height = num(values[`${prefix}_height_m`], num(defaults[`${prefix}_height_m`], 0.0));
|
||||
const installed = String(install ?? "").trim() !== "없음";
|
||||
return {
|
||||
installed,
|
||||
height_m: height,
|
||||
length_m: length,
|
||||
angle_deg: angle,
|
||||
slab_extend_m: wingSlabExtendM(installed, length, angle ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** 세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장). */
|
||||
function fordSet(
|
||||
values: Record<string, unknown>,
|
||||
registry: CulvertRegistryDefaults,
|
||||
): CulvertSetSpec {
|
||||
const defaults = registry.ford_bridge ?? {};
|
||||
const diameterMm = num(values.pipe_diameter_mm, num(defaults.pipe_diameter_mm, 1000.0));
|
||||
const width = num(values.ford_width_m, num(defaults.ford_width_m, null));
|
||||
const count = num(values.pipe_count, num(defaults.pipe_count, null));
|
||||
const depth = num(values.ford_height_m, null);
|
||||
return {
|
||||
type: "ford",
|
||||
pipe_kind: text(values.pipe_kind) ?? text(defaults.pipe_kind),
|
||||
diameter_m: pipeDiameterM(diameterMm),
|
||||
pipe_count: count ? Math.max(Math.trunc(count), 1) : 1,
|
||||
span_m: width && width > 0 ? width : FORD_DEFAULT_WIDTH_M,
|
||||
// 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다. 없으면 0 = 내리지 않는다.
|
||||
overflow_depth_m: depth && depth > 0 ? depth : 0.0,
|
||||
slab_thickness_m: FORD_SLAB_THICKNESS_M,
|
||||
wall_thickness_m: FORD_WALL_THICKNESS_M,
|
||||
min_cover_m: MIN_PIPE_COVER_M,
|
||||
wing_in: wingSpec(values, defaults, "in"),
|
||||
wing_out: wingSpec(values, defaults, "out"),
|
||||
};
|
||||
}
|
||||
|
||||
/** 물넘이포장 1개소 — 구조물이 아니라 **파인 노면**이라 형상이 다르다. */
|
||||
function fordPavementSet(
|
||||
values: Record<string, unknown>,
|
||||
registry: CulvertRegistryDefaults,
|
||||
): CulvertSetSpec {
|
||||
const defaults = registry.ford_pavement ?? {};
|
||||
const width = num(values.ford_width_m, num(defaults.ford_width_m, null));
|
||||
const depth = num(values.ford_height_m, null);
|
||||
return {
|
||||
type: "ford_pavement",
|
||||
span_m: width && width > 0 ? width : FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||
// 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
|
||||
depth_m: depth && depth > 0 ? depth : null,
|
||||
slope_pct: num(values.ford_slope_pct, null),
|
||||
};
|
||||
}
|
||||
|
||||
/** BOX암거 1개소의 세트 제원(구체 + 날개벽 연장). */
|
||||
function boxSet(
|
||||
values: Record<string, unknown>,
|
||||
registry: CulvertRegistryDefaults,
|
||||
): CulvertSetSpec {
|
||||
const defaults = registry.box_culvert ?? {};
|
||||
const innerWidth = num(values.body_width_m, num(defaults.body_width_m, 2.0));
|
||||
const innerHeight = num(values.body_height_m, num(defaults.body_height_m, 2.0));
|
||||
const wall = FORD_WALL_THICKNESS_M;
|
||||
const slab = FORD_SLAB_THICKNESS_M;
|
||||
return {
|
||||
type: "box",
|
||||
inner_width_m: innerWidth || 2.0,
|
||||
inner_height_m: innerHeight || 2.0,
|
||||
wall_thickness_m: wall,
|
||||
slab_thickness_m: slab,
|
||||
top_thickness_m: slab,
|
||||
cover_m: BOX_COVER_M,
|
||||
span_m: boxSpanM(innerWidth || 2.0),
|
||||
wing_in: wingSpec(values, defaults, "in"),
|
||||
wing_out: wingSpec(values, defaults, "out"),
|
||||
};
|
||||
}
|
||||
|
||||
/** 관 지점 목록 → 누가거리(소수 2자리)별 세트 제원. */
|
||||
export function buildCulvertSets(
|
||||
points: readonly CulvertPipePoint[],
|
||||
registry: CulvertRegistryDefaults,
|
||||
): Map<number, CulvertSetSpec> {
|
||||
const sets = new Map<number, CulvertSetSpec>();
|
||||
for (const point of points) {
|
||||
const chainage = num(point.chainage_m, null);
|
||||
if (chainage === null) continue;
|
||||
const values = (point.options ?? {}) as Record<string, unknown>;
|
||||
const facility = point.facility || FACILITY_PIPE;
|
||||
let spec: CulvertSetSpec;
|
||||
if (facility === FACILITY_FORD_BRIDGE) spec = fordSet(values, registry);
|
||||
else if (facility === FACILITY_BOX) spec = boxSet(values, registry);
|
||||
else if (facility === FACILITY_FORD_PAVEMENT) spec = fordPavementSet(values, registry);
|
||||
else if (facility === FACILITY_REVET) spec = revetSet(values);
|
||||
else spec = culvertSet(values, registry);
|
||||
sets.set(round(chainage, 2), spec);
|
||||
}
|
||||
return sets;
|
||||
}
|
||||
|
||||
/** 측점 자료에 세트를 얹는다(파이썬 `attach_culvert_sets`). 얹은 개수를 돌려준다. */
|
||||
export function attachCulvertSets(
|
||||
sections: Array<Record<string, unknown>>,
|
||||
sets: ReadonlyMap<number, CulvertSetSpec>,
|
||||
): number {
|
||||
if (sets.size === 0) return 0;
|
||||
let attached = 0;
|
||||
for (const section of sections) {
|
||||
const chainage = num(section.chainage_m, null);
|
||||
if (chainage === null) continue;
|
||||
for (const [pipeChainage, spec] of sets) {
|
||||
// 연동 대상 종류만 폭의 절반까지 옆 측점에 걸친다.
|
||||
let reach = CHAINAGE_TOLERANCE_M;
|
||||
if (SPAN_LINKED_TYPES.has(String(spec.type))) reach += (num(spec.span_m, 0) ?? 0) / 2;
|
||||
if (Math.abs(chainage - pipeChainage) <= reach) {
|
||||
section[SECTION_KEYS[String(spec.type)] ?? "culvert"] = spec;
|
||||
attached += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return attached;
|
||||
}
|
||||
|
||||
/** 레지스트리 응답(타입 목록) → 기본값 표. 옵션 정의가 유일한 기본값 출처다. */
|
||||
export function registryDefaults(
|
||||
types: ReadonlyArray<{
|
||||
type_id: string;
|
||||
options: ReadonlyArray<{ key: string; default: unknown }>;
|
||||
}>,
|
||||
): CulvertRegistryDefaults {
|
||||
const table: CulvertRegistryDefaults = {};
|
||||
for (const type of types) {
|
||||
const entry: Record<string, unknown> = {};
|
||||
for (const option of type.options) entry[option.key] = option.default;
|
||||
table[type.type_id] = entry;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
Reference in New Issue
Block a user