From 1d07633260bc4d1726eb8c9df39a9f590f86252b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 17 Aug 2026 08:54:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EB=B3=91=ED=95=A9=201?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=E2=80=94=20=EA=B3=84=EA=B3=A1=20=ED=86=B5?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=9C=EC=84=A4=C2=B7=EA=B5=AC=EA=B0=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=A0=90=C2=B7phase=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN 2026-08-17 「B05 구조물 컨테이너 병합」 백엔드. 배관 정본은 유역 계산의 입력이라 저장소를 옮기지 않고 제자리 확장한다. - PipePoint: facility(배관/BOX암거/물넘이/세월교)·start_m/end_m(기준점 앞뒤 구간)·options(세월교 관 종류/크기/수량) 추가. 구 파일은 배관·폭 미지정으로 읽히고 기본값은 저장 시 생략된다(하위 호환). 교량은 임도용이 아니라 없다. - carry_facility_attributes: 세부유역 계산기는 chainage만 다뤄 재구성 목록이 전부 기본 배관이 된다 — 원본에서 종류·구간·옵션을 되붙인다. B04 basins 라우터의 재구성 지점에 적용하고 응답·GeoJSON에도 시설 정보를 싣는다. - StructureInstance: 구간형 chainage_m = 기준점(마킹 위치) 허용, 시작≤기준≤종료 검증, 미지정 시 시점으로 채움(기존 저장분 호환). anchor_m = 기준점. - 레지스트리: 옵션 phase 필드(b05/detail) 신설. required 32건을 detail로 이동 — 필수 원칙은 유지하고 강제 시점만 B06/B07로 미룬다(B05 = 유무·종류·위치 단계, 2026-08-17 사용자 확정). BOX암거·물넘이·세월교(표시용, managed_by= pipe_points)와 A6 노출형 횡단수로·A7 개거(수동) 5종 추가, 총 37종. - Repository: phase=detail 옵션은 B05 저장에서 필수 강제 제외. b05 필수는 기존대로 강제. tmp/tests 88건 통과 (신규: pipe facility 16·span anchor 10·registry 정책 재편). Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_PreProcess_Router_Basins.py | 20 +- B05_Profile/B05_Profile_Structure_Types.json | 224 +++++++++++++++--- .../B05_Profile_Structures_Repository.py | 4 + B05_Profile/B05_Profile_Structures_Schema.py | 17 +- common_util/common_util_drainage_pipes.py | 83 ++++++- 5 files changed, 299 insertions(+), 49 deletions(-) diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index d2ceaff9..b8cf8e73 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -28,6 +28,7 @@ from common_util.common_util_drainage_detail import DrainageDetail, build_detail from common_util.common_util_drainage_pipes import ( PIPE_SOURCE_USER, PipePoint, + carry_facility_attributes, clear_pipe_points, load_pipe_points, parse_pipe_points, @@ -60,9 +61,10 @@ def _build( return None, [] if points: _retag(detail.pipes, points) - return detail, [ - PipePoint(chainage_m=pipe.chainage_m, source=pipe.reason) for pipe in detail.pipes - ] + 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 []) def _retag(pipes: list[StructureCandidate], points: list[PipePoint]) -> None: @@ -107,13 +109,15 @@ def _payload( "flow_arrows": detail.flow_arrows, "arrow_spacing_m": detail.arrow_spacing_m, "upstream_lonlat": detail.upstream_lonlat, + # 시설 종류·구간·옵션까지 함께 싣는다(as_dict — 기본 배관·폭 0은 필드 생략). "pipe_points": [ { + **point.as_dict(), "chainage_m": round(pipe.chainage_m, 2), "lonlat": list(to_lonlat(pipe.x, pipe.y)), "source": pipe.reason, } - for pipe in detail.pipes + for pipe, point in zip(detail.pipes, points) ], "basins": [ { @@ -172,11 +176,9 @@ def _basin_features( features.append( { "type": "Feature", - "properties": { - "kind": "pipe", - "chainage_m": round(point.chainage_m, 2), - "source": point.source, - }, + # 시설 종류·구간·옵션도 함께 남긴다(as_dict — 기본값 생략) — GeoJSON만 + # 보고도 세월교인지 배관인지 알 수 있어야 한다. + "properties": {"kind": "pipe", **point.as_dict()}, "geometry": {"type": "Point", "coordinates": list(to_lonlat(pipe.x, pipe.y))}, } ) diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index f7779a40..a7935f6b 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -21,6 +21,132 @@ ], "options": [] }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "구체 내 배관 종류", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": null, + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "배관 크기", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": null, + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": null, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, { "type_id": "ditch_side", "group": "B", @@ -47,7 +173,8 @@ "J형" ], "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "depth_cm", @@ -83,7 +210,8 @@ "떼" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -130,7 +258,8 @@ "반원관" ], "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "section", @@ -142,7 +271,8 @@ "사다리꼴" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -169,7 +299,8 @@ "돌" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -223,7 +354,8 @@ "부벽식" ], "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "height_m", @@ -231,7 +363,8 @@ "input": "number", "unit": "m", "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -256,7 +389,8 @@ "input": "number", "unit": "m", "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "back_len_cm", @@ -272,7 +406,8 @@ "75" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -297,7 +432,8 @@ "input": "number", "unit": "m", "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "back_len_cm", @@ -313,7 +449,8 @@ "75" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -346,7 +483,8 @@ "통나무쌓기" ], "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "height_m", @@ -354,7 +492,8 @@ "input": "number", "unit": "m", "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -379,7 +518,8 @@ "input": "number", "unit": "m", "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "stone_cm", @@ -391,7 +531,8 @@ "80~100" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -424,7 +565,8 @@ "흙" ], "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "length_m", @@ -464,7 +606,8 @@ "input": "number", "unit": "m", "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -489,7 +632,8 @@ "input": "number", "unit": "m", "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "apron", @@ -500,7 +644,8 @@ "없음" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -533,7 +678,8 @@ "바자" ], "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "height_m", @@ -541,7 +687,8 @@ "input": "number", "unit": "m", "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -566,7 +713,8 @@ "input": "number", "unit": "m", "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "length_m", @@ -654,7 +802,8 @@ "양면" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -683,7 +832,8 @@ "위험표지" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -751,7 +901,8 @@ "input": "number", "unit": "㎡", "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "capacity_m3", @@ -759,7 +910,8 @@ "input": "number", "unit": "㎥", "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -783,7 +935,8 @@ "input": "number", "unit": "㎡", "default": null, - "required": true + "required": true, + "phase": "detail" }, { "key": "capacity_m3", @@ -791,7 +944,8 @@ "input": "number", "unit": "㎥", "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -818,7 +972,8 @@ "자연형계단" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -878,7 +1033,8 @@ "나무심기" ], "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -903,7 +1059,8 @@ "input": "number", "unit": "cm", "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -927,7 +1084,8 @@ "input": "number", "unit": "cm", "default": null, - "required": true + "required": true, + "phase": "detail" } ] }, @@ -953,4 +1111,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/B05_Profile/B05_Profile_Structures_Repository.py b/B05_Profile/B05_Profile_Structures_Repository.py index fbfdd62f..4ddfa9e7 100644 --- a/B05_Profile/B05_Profile_Structures_Repository.py +++ b/B05_Profile/B05_Profile_Structures_Repository.py @@ -162,6 +162,10 @@ def _validate_options(item: StructureInstance, definition) -> None: elif option.input == "select" and option.choices and str(value) not in option.choices: raise ValueError(f"{definition.name}의 {option.label} 값이 선택지에 없습니다: {value}") for option in definition.options: + # phase=detail은 B06/B07 상세 단계 입력 — B05 저장(유무·종류·위치)에서는 + # 강제하지 않는다. 필수 원칙은 유지되고 시점만 미뤄진다(2026-08-17 사용자 확정). + if option.phase == "detail": + continue if option.required and item.options.get(option.key) in (None, ""): raise ValueError( f"{definition.name}의 {option.label}은(는) 필수 입력입니다 — " diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 62ba1adb..486ff705 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -40,6 +40,10 @@ class StructureOptionField(BaseModel): # 미확정 수치(기본값 없음)는 사용자가 직접 넣어야 저장된다 — 지식DB 원칙: # 기본값 선정은 사용자 협의 영역, 임의값 자동 저장 금지(2026-08-16 크로스체크 반영). required: bool = False + # 입력 시점 — B05는 유무·종류·위치만 고르고 상세 치수는 B06/B07에서 받는다 + # (2026-08-17 사용자 확정). `detail`이면 required여도 B05 저장에서 강제하지 않는다 + # — 필수 원칙은 유지되고 강제 시점만 B06/B07로 미뤄진다. + phase: Literal["b05", "detail"] = "b05" class StructureType(BaseModel): @@ -111,8 +115,12 @@ class StructureInstance(BaseModel): raise ValueError("구간형 구조물은 start_m과 end_m이 모두 필요합니다.") if self.end_m <= self.start_m: raise ValueError("구간형 구조물의 end_m은 start_m보다 커야 합니다.") - if self.chainage_m is not None: - raise ValueError("구간형 구조물에는 chainage_m을 함께 보낼 수 없습니다.") + # 구간형 chainage_m = 기준점(마킹 위치). 기존 저장분에는 없으므로 시점으로 + # 채운다 (2026-08-17 사용자 확정: 기준점에 마킹 + 시작·종료 측점). + if self.chainage_m is None: + self.chainage_m = self.start_m + elif not (self.start_m <= self.chainage_m <= self.end_m): + raise ValueError("구간형 구조물의 기준점은 시작~종료 측점 안에 있어야 합니다.") else: if self.chainage_m is None: raise ValueError("점형·부지형 구조물은 chainage_m이 필요합니다.") @@ -121,8 +129,9 @@ class StructureInstance(BaseModel): return self def anchor_m(self) -> float: - """종단도 서클마크 위치 — 구간형은 기점(시점)에 찍는다(2026-08-16 사용자 확정).""" - return self.start_m if self.placement == "interval" else self.chainage_m # type: ignore[return-value] + """종단도 서클마크 위치 = 기준점. 구간형도 chainage_m이 기준점이다 + (2026-08-17 사용자 확정 — validator가 미지정 시 시점으로 채워 둔다).""" + return self.chainage_m # type: ignore[return-value] class StructureTypesResponse(BaseModel): diff --git a/common_util/common_util_drainage_pipes.py b/common_util/common_util_drainage_pipes.py index b126f599..c4352adc 100644 --- a/common_util/common_util_drainage_pipes.py +++ b/common_util/common_util_drainage_pipes.py @@ -36,16 +36,53 @@ PIPE_SOURCE_SPACING = "spacing" # 자동 보충 — 관 최대 간격 규칙 PIPE_SOURCE_USER = "user" # 수동 — 사용자가 우클릭으로 추가하거나 옮긴 관 _KNOWN_SOURCES = (PIPE_SOURCE_STREAM, PIPE_SOURCE_SPACING, PIPE_SOURCE_USER) +# 계곡 통과 시설 종류 (2026-08-17 컨테이너 병합). 같은 계곡 교차 지점에서 유량·지형에 +# 따라 택일하는 관계라 별도 정본을 만들지 않고 관 지점에 종류만 얹는다 — 유역 계산은 +# 기준점(chainage_m)만 읽으므로 어느 종류든 계산이 같다. 교량은 임도용이 아니라 없다 +# (2026-08-17 사용자 확정). +PIPE_FACILITY_PIPE = "pipe" # 배관(횡단배수관) — 기본 +PIPE_FACILITY_BOX = "box_culvert" # BOX암거 +PIPE_FACILITY_FORD_PAVEMENT = "ford_pavement" # 물넘이포장 +PIPE_FACILITY_FORD_BRIDGE = "ford_bridge" # 세월교 +_KNOWN_FACILITIES = ( + PIPE_FACILITY_PIPE, + PIPE_FACILITY_BOX, + PIPE_FACILITY_FORD_PAVEMENT, + PIPE_FACILITY_FORD_BRIDGE, +) + @dataclass class PipePoint: - """계획선 위 관 매설 지점 한 개.""" + """계획선 위 계곡 통과 시설 한 개 (배관·BOX암거·물넘이·세월교). + + `chainage_m`가 기준점(계곡 교차, 종단 마킹 위치)이고, `start_m`/`end_m`는 유입·유출 + 부속이 차지하는 앞뒤 구간이다. 구간 미지정(None)은 폭 0 — 자동 배치분의 기본값이며 + 사용자가 필요할 때 벌린다. 상세 치수는 B06/B07 몫이라 여기에는 유무·종류 수준의 + `options`(예: 세월교 관 종류/크기/수량)만 둔다 (2026-08-17 사용자 확정). + """ chainage_m: float source: str = PIPE_SOURCE_USER + facility: str = PIPE_FACILITY_PIPE + start_m: float | None = None + end_m: float | None = None + options: dict[str, Any] | None = None def as_dict(self) -> dict[str, Any]: - return {"chainage_m": round(float(self.chainage_m), 2), "source": self.source} + # 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다. + payload: dict[str, Any] = { + "chainage_m": round(float(self.chainage_m), 2), + "source": self.source, + } + if self.facility != PIPE_FACILITY_PIPE: + payload["facility"] = self.facility + if self.start_m is not None and self.end_m is not None: + payload["start_m"] = round(float(self.start_m), 2) + payload["end_m"] = round(float(self.end_m), 2) + if self.options: + payload["options"] = self.options + return payload def edits_dir(stored_path: str) -> Path: @@ -90,8 +127,19 @@ def load_pipe_points(stored_path: str, signature: str) -> list[PipePoint] | None return parse_pipe_points(document.get("points")) +def _parse_span(item: dict[str, Any], chainage: float) -> tuple[float | None, float | None]: + """시작·종료 구간을 정규화한다 — 기준점을 항상 품고, 뒤집힘은 바로잡는다.""" + raw_start, raw_end = item.get("start_m"), item.get("end_m") + start = float(raw_start) if isinstance(raw_start, (int, float)) else None + end = float(raw_end) if isinstance(raw_end, (int, float)) else None + if start is None and end is None: + return None, None + values = [value for value in (start, end) if value is not None] + [chainage] + return min(values), max(values) + + def parse_pipe_points(values: Any) -> list[PipePoint]: - """외부에서 들어온 관 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순.""" + """외부에서 들어온 시설 목록(파일·요청 본문)을 정리한다. 누가거리 오름차순.""" if not isinstance(values, list): return [] points: list[PipePoint] = [] @@ -105,16 +153,45 @@ def parse_pipe_points(values: Any) -> list[PipePoint]: if not isinstance(chainage, (int, float)): continue source = str(item.get("source") or PIPE_SOURCE_USER) + facility = str(item.get("facility") or PIPE_FACILITY_PIPE) + start, end = _parse_span(item, float(chainage)) + options = item.get("options") points.append( PipePoint( chainage_m=float(chainage), source=source if source in _KNOWN_SOURCES else PIPE_SOURCE_USER, + facility=facility if facility in _KNOWN_FACILITIES else PIPE_FACILITY_PIPE, + start_m=start, + end_m=end, + options=dict(options) if isinstance(options, dict) and options else None, ) ) points.sort(key=lambda point: point.chainage_m) return points +def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) -> list[PipePoint]: + """계산기를 거쳐 재구성된 목록에 시설 종류·구간·옵션을 되붙인다. + + 세부유역 계산기는 chainage만 다루므로 계산에서 돌아온 목록은 전부 기본 배관이 된다. + 그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다. 좌표가 미세 + 조정(스냅)될 수 있어 정확 일치가 없으면 가장 가까운 원본에서 승계한다 — 생성 사유를 + 되붙이는 `_retag`(B04 라우터)과 같은 기준이다. + """ + if not reference: + return base + by_key = {round(ref.chainage_m, 2): ref for ref in reference} + for point in base: + ref = by_key.get(round(point.chainage_m, 2)) + if ref is None: + ref = min(reference, key=lambda item: abs(item.chainage_m - point.chainage_m)) + point.facility = ref.facility + point.start_m = ref.start_m + point.end_m = ref.end_m + point.options = ref.options + return base + + def save_pipe_points(stored_path: str, signature: str, points: list[PipePoint]) -> int: """관 지점을 정본 파일에 쓴다. 저장된 개수를 돌려준다.""" path = pipe_points_path(stored_path)