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>
329 lines
12 KiB
Python
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()
|