Files
Aislo/resources/data_global_contours/analyze_contour_source.py
T
eomsangdonandClaude Fable 5 81cd7e23c3 feat(knowledge): 도메인 지식저장소 메인 통합 + resources 그룹 체계 재편
knowledge (구 Aislo-law 독립 저장소 → resources/knowledge 이관, 저장소 폐지):
- 법령·행정규칙·표준시방서·교본 원문 + 기술문서 55건 + 실무 분석·종합비교
- 루트 지침 체계: README(지도)·00_운영지침·01_수집지침·02_분석지침·
  03_미결_및_확인사항(교본 충돌 리스트 포함)·04_참조_법령기준_목록
- 기술문서 55건 원문 전수 검증 완료 (사방 설계홍수량 법정 기준 등 반영)
- 정리: CAD·오피스 잔재 142건, 중복 zip 7건(413MB), 빈 폴더 30개 제거

resources 그룹 재편 (이름순 그룹핑):
- app_branding(구 prog_icon.jpg)·app_policies(구 legal)·
  data_global_contours(구 grobal_contours)·data_rainfall_idf_cache(구 wamis_contours)·
  template_2dDrawing(구 dwg_analysis/templete — 오타 교정, 상수·경로 동기화)
- dwg_analysis(분석 완료 1.8GB)·templates(빈 폴더)·templete_calc_cost.xlsx 삭제
- 참조 코드 5파일 경로 수정 + 프론트 재빌드 (구 경로 잔존 0)
- .gitignore: resources 추적 전환, national_contours.gpkg(22GB) 영구 제외
- .env: knowledge 수집용 API 정보 주석 통합 (KCSC·법령센터·조달청 제비율)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:14:32 +09:00

329 lines
12 KiB
Python

"""국가 등고선 원본 데이터 분석 스크립트 (1차).
국가기본도(TN_CTRLN)와 연속수치지형도(N3L_F0010000)는 컬럼 체계가 서로 다르므로
스키마를 자동 판별한 뒤 동일한 지표로 환산해 비교한다.
측정 항목
1. 데이터 개요 : 소스 수, 레코드 수, 좌표계, 전체 범위
2. 등고선 종류 : 계곡선/주곡선/간곡선/조곡선 분포와 실측 등고 간격
3. 지형 구분 : 볼록지/오목지 분포와 오목지 폐합률
4. 지오메트리 정밀도: 평균 정점간격, 라인 길이 분포
5. 요약 판정 : 실질 등고 간격, 지성선 포함 여부
용어 주의
계곡선(計曲線)은 주곡선 5개마다 굵게 그리는 index contour이며 골짜기와 무관하다.
오목지(凹地)는 폐합된 저지(웅덩이·분지)를 뜻하며 골짜기가 아니다.
[사전 필수 패키지]
pip install geopandas pyogrio shapely
사용법
python analyze_contour_source.py <경로> [--sample 20000] [--encoding cp949]
<경로> : .zip | .shp | SHP가 들어있는 폴더
"""
import argparse
import glob
import os
import sys
import zipfile
from collections import Counter
try:
import numpy as np
import pyogrio
except ImportError:
print("[오류] 필수 라이브러리가 누락되었습니다.")
print(">>> pip install geopandas pyogrio shapely")
sys.exit(1)
# 국가기본도: 등고선 구분과 지형 구분이 각각 독립 컬럼으로 존재한다.
PROFILE_NGII = {
"id": "NGII",
"name": "국가기본도 (TN_CTRLN)",
"must_have": ("CTRLN_SE", "TPGRPH_SE", "CTRLN_HG"),
"kind_field": "CTRLN_SE",
"kind_codes": {
"CTC001": "계곡선",
"CTC002": "주곡선",
"CTC003": "간곡선",
"CTC004": "조곡선",
},
"topo_field": "TPGRPH_SE",
"topo_codes": {"TPC001": "볼록지", "TPC002": "오목지"},
"height_field": "CTRLN_HG",
"extra_fields": ("MESRMTH_SE",),
}
# 연속수치지형도: 지형 구분이 통합코드(SCLS) 안에 인코딩되어 있다.
PROFILE_SLDM = {
"id": "SLDM",
"name": "연속수치지형도 (N3L_F0010000)",
"must_have": ("DIVI", "CONT", "SCLS"),
"kind_field": "DIVI",
"kind_codes": {
"CTD001": "계곡선",
"CTD002": "주곡선",
"CTD003": "간곡선",
"CTD004": "조곡선",
},
"topo_field": "SCLS",
"topo_codes": {"F001711": "볼록지", "F001712": "오목지"},
"height_field": "CONT",
"extra_fields": (),
}
PROFILES = (PROFILE_NGII, PROFILE_SLDM)
def collect_sources(path: str) -> list[str]:
"""입력 경로에서 읽을 수 있는 SHP 목록을 만든다 (zip은 /vsizip/ 경로로 변환)."""
path = os.path.abspath(path)
if path.lower().endswith(".zip"):
with zipfile.ZipFile(path) as zf:
inner = sorted(n for n in zf.namelist() if n.lower().endswith(".shp"))
base = "/vsizip/" + path.replace("\\", "/")
return [f"{base}/{n}" for n in inner]
if path.lower().endswith(".shp"):
return [path]
if os.path.isdir(path):
found = sorted(glob.glob(os.path.join(path, "**", "*.shp"), recursive=True))
if found:
return found
# 폴더 안에 zip만 있는 경우 재귀 처리
out: list[str] = []
for z in sorted(glob.glob(os.path.join(path, "**", "*.zip"), recursive=True)):
out.extend(collect_sources(z))
return out
return []
def detect_profile(fields: list[str]) -> dict | None:
"""필드명으로 데이터 제품을 판별한다."""
names = set(fields)
for profile in PROFILES:
if names.issuperset(profile["must_have"]):
return profile
return None
def topo_label(profile: dict, value) -> str:
"""지형 구분 값을 볼록지/오목지 라벨로 환산한다."""
if value is None:
return "미상"
text = str(value)
if profile["id"] == "NGII":
return profile["topo_codes"].get(text, f"정의외({text})")
for prefix, label in profile["topo_codes"].items():
if text.startswith(prefix):
return label
return f"정의외({text})"
def scan_attributes(sources: list[str], profile: dict, encoding: str) -> dict:
"""속성만 전수 조회하여 분포를 집계한다 (지오메트리는 읽지 않음)."""
kind = Counter()
topo = Counter()
cross = Counter()
extra = {f: Counter() for f in profile["extra_fields"]}
heights: dict[str, set] = {}
per_source: list[tuple[str, int]] = []
bounds = None
total = 0
for src in sources:
df = pyogrio.read_dataframe(src, read_geometry=False, encoding=encoding)
total += len(df)
per_source.append((os.path.basename(src), len(df)))
# 소스마다 범위가 다르므로 합집합을 누적한다 (첫 소스 범위만 쓰면 전국이 아니다).
box = pyogrio.read_info(src, encoding=encoding).get("total_bounds")
if box is not None:
bounds = (
box
if bounds is None
else (
min(bounds[0], box[0]),
min(bounds[1], box[1]),
max(bounds[2], box[2]),
max(bounds[3], box[3]),
)
)
kinds = [
profile["kind_codes"].get(str(v), f"정의외({v})") for v in df[profile["kind_field"]]
]
topos = [topo_label(profile, v) for v in df[profile["topo_field"]]]
kind.update(kinds)
topo.update(topos)
cross.update(zip(kinds, topos))
for f in extra:
if f in df.columns:
extra[f].update(df[f].tolist())
for k, h in zip(kinds, df[profile["height_field"]]):
if h is None:
continue
heights.setdefault(k, set()).add(round(float(h), 2))
return {
"total": total,
"per_source": per_source,
"kind": kind,
"topo": topo,
"cross": cross,
"extra": extra,
"heights": heights,
"bounds": bounds,
}
def scan_geometry(src: str, profile: dict, encoding: str, sample: int) -> dict:
"""대표 소스 1개에서 지오메트리를 표본 추출해 형상 정밀도를 측정한다."""
result: dict = {"source": os.path.basename(src), "sample": 0}
gdf = pyogrio.read_dataframe(src, encoding=encoding, max_features=sample)
gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty]
if not len(gdf):
return result
lengths = np.array([g.length for g in gdf.geometry])
verts = int(sum(len(g.coords) for g in gdf.geometry if g.geom_type == "LineString"))
parts = int(sum(1 for g in gdf.geometry if g.geom_type == "LineString"))
result.update(
{
"sample": len(gdf),
"crs": str(gdf.crs),
"total_len": float(lengths.sum()),
"len_median": float(np.median(lengths)),
"len_max": float(lengths.max()),
"vertices": verts,
"vertex_gap": float(lengths.sum() / max(verts - parts, 1)),
}
)
# 오목지 폐합률: 오목지가 웅덩이인지 골짜기인지 판별하는 핵심 지표
for label in ("오목지", "볼록지"):
mask = [topo_label(profile, v) == label for v in gdf[profile["topo_field"]]]
subset = gdf[mask]
if not len(subset):
continue
closed = [
list(g.coords)[0] == list(g.coords)[-1]
for g in subset.geometry
if g.geom_type == "LineString"
]
if closed:
result[f"closed_{label}"] = (sum(closed) / len(closed), len(closed))
return result
def interval_of(values: set) -> list:
"""등고 높이 값 집합에서 실측 간격 종류를 뽑는다."""
vals = sorted(values)
return sorted({round(vals[i + 1] - vals[i], 2) for i in range(len(vals) - 1)})
def report(path: str, profile: dict, attrs: dict, geom: dict, bounds) -> None:
"""분석 결과를 표준 출력에 정리한다."""
total = attrs["total"]
line = "=" * 74
print(line)
print("등고선 원본 분석 : %s" % path)
print("판별된 제품 : %s" % profile["name"])
print(line)
print("\n[1] 데이터 개요")
print(" 소스 %d개, 총 레코드 %s건" % (len(attrs["per_source"]), format(total, ",")))
if geom.get("crs"):
print(" 좌표계 %s" % geom["crs"])
if bounds:
print(
" 전체 범위 X %.0f~%.0f / Y %.0f~%.0f" % (bounds[0], bounds[2], bounds[1], bounds[3])
)
for name, cnt in attrs["per_source"][:15]:
print(" %-24s %s" % (name, format(cnt, ",")))
if len(attrs["per_source"]) > 15:
print(" ... 외 %d개" % (len(attrs["per_source"]) - 15))
print("\n[2] 등고선 종류 (계곡선=index contour, 골짜기 아님)")
for k, v in attrs["kind"].most_common():
print(" %-8s %10s (%5.2f%%)" % (k, format(v, ","), v / total * 100))
for k in ("간곡선", "조곡선"):
if k not in attrs["kind"]:
print(" %-8s %10s (스펙 정의 있으나 실데이터 없음)" % (k, "0"))
print("\n 실측 등고 간격")
for k in sorted(attrs["heights"]):
vals = sorted(attrs["heights"][k])
print(
" %-8s%d종, 범위 %g ~ %g m, 간격종류 %s"
% (k, len(vals), vals[0], vals[-1], interval_of(attrs["heights"][k])[:6])
)
print("\n[3] 지형 구분 (오목지=폐합 저지, 골짜기 아님)")
for k, v in attrs["topo"].most_common():
print(" %-10s %10s (%5.2f%%)" % (k, format(v, ","), v / total * 100))
print("\n 종류 x 지형 교차")
for (a, b), v in attrs["cross"].most_common(10):
print(" %-8s x %-10s %10s" % (a, b, format(v, ",")))
for field, counter in attrs["extra"].items():
if counter:
print("\n %s" % field)
for k, v in counter.most_common(6):
print(" %-10s %10s (%5.2f%%)" % (k, format(v, ","), v / total * 100))
print(
"\n[4] 지오메트리 정밀도 (표본 %s건 / %s)"
% (format(geom.get("sample", 0), ","), geom.get("source", "-"))
)
if geom.get("sample"):
print(" 평균 정점간격 %.2f m" % geom["vertex_gap"])
print(" 라인 길이 중앙값 %.0f m, 최대 %.0f m" % (geom["len_median"], geom["len_max"]))
for label in ("볼록지", "오목지"):
key = f"closed_{label}"
if key in geom:
ratio, n = geom[key]
print(" %s 폐합률 %.1f%% (표본 %s개)" % (label, ratio * 100, format(n, ",")))
print("\n[5] 요약 판정")
main = attrs["heights"].get("주곡선")
if main:
iv = interval_of(main)
print(" 실질 등고 간격 : %g m (주곡선 기준)" % (iv[0] if iv else 0))
has_fine = any(k in attrs["kind"] for k in ("간곡선", "조곡선"))
print(" 간곡선/조곡선 : %s" % ("있음" if has_fine else "없음 → 1m 보간 필요"))
print(" 볼록지/오목지 : %s" % ("있음" if "오목지" in attrs["topo"] else "없음"))
print(" 능선/계곡 지성선: 없음 (등고선 레이어에는 포함되지 않는 항목)")
print(line)
def main() -> None:
parser = argparse.ArgumentParser(description="국가 등고선 원본 데이터 분석")
parser.add_argument("path", help=".zip | .shp | SHP가 든 폴더")
parser.add_argument("--sample", type=int, default=20000, help="지오메트리 표본 수 (기본 20000)")
parser.add_argument("--encoding", default="cp949", help="DBF 인코딩 (기본 cp949)")
args = parser.parse_args()
sources = collect_sources(args.path)
if not sources:
print("[오류] 읽을 SHP를 찾지 못했습니다: %s" % args.path)
sys.exit(1)
info = pyogrio.read_info(sources[0], encoding=args.encoding)
profile = detect_profile(list(info["fields"]))
if profile is None:
print("[오류] 알 수 없는 스키마입니다. 필드: %s" % list(info["fields"]))
sys.exit(1)
attrs = scan_attributes(sources, profile, args.encoding)
geom = scan_geometry(sources[0], profile, args.encoding, args.sample)
report(args.path, profile, attrs, geom, attrs["bounds"])
if __name__ == "__main__":
main()