From 66f9c68a1e8becc0672fff4d0d5ae8cf4b8d0dec Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:18:43 +0900 Subject: [PATCH 1/7] =?UTF-8?q?perf(D03):=200=EC=B0=A8=EA=B3=A1=20=3D=20?= =?UTF-8?q?=EC=B0=BD=EA=B3=A0=20=EA=B2=A9=EC=9E=90=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EC=97=BD=20=EC=84=A0=20=EB=AF=B8=EB=A6=AC=20=EB=BD=91=EC=95=84?= =?UTF-8?q?=20=EC=9D=B4=EC=96=B4=20=EB=B6=99=EC=9E=84=20=C2=B7=20=EB=AC=B4?= =?UTF-8?q?=EA=B1=B0=EC=9A=B4=20=EC=85=88=20=EC=9D=BC=EA=BE=BC=20(PLAN=206?= =?UTF-8?q?16-1=20=C2=B7=20616-2=20=C2=B7=20616-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0차곡 셈 = 사업 DEM 대신 창고 격자(받은 범위 도엽 누적) · 옛 격자 통째 갈래 걷음 - 기본 임계 선 = 도엽마다 창고 `zero_lines__2m.npz`(여유 200 m 에서 뽑아 끝점 도엽만 · 단순화 끝) · 요청 = 이어 붙여 받은 범위로 자름(파생 저장본 안 씀) · 다른 임계 = 누적 이어 붙여 셈 · 저장본 - 창고 누적 열쇠 판 그대로(ACC_VERSION 2 · 전국 누적 다시 셈 없음) · 파생 판 3 - compute=False = 셈 필요하면 None · 서버 본체 밖 일꾼 하나(`heavy_pool` · 띄울 때 빈 __main__ · 서버 끝나면 따라 끝남) - 미리 계산 단계 zero_lines 덧(zero 뒤 · 기본 단계에 듦) - 시험 test_616 새로 · test_608 반 칸 어긋난 DEM 통째 셈 확인 걷음(갈래 없어짐) · test_584_1 사업 DEM 통째 0차곡 겹 시험 걷음(창고 길로 바뀜 · 616 시험이 대신) Co-Authored-By: Claude Opus 5.5 --- .../D03_PlanAnalysis_ZeroOrder.py | 444 ++++++++++++++---- resources/tester/test_584_1_plan_geo_lines.py | 25 - .../tester/test_608_plan_zero_order_store.py | 6 +- resources/tester/test_616_zero_order_fast.py | 189 ++++++++ tools/precompute_terrain.py | 25 +- 5 files changed, 551 insertions(+), 138 deletions(-) create mode 100644 resources/tester/test_616_zero_order_fast.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_ZeroOrder.py b/D03_PlanAnalysis/D03_PlanAnalysis_ZeroOrder.py index b4f0c4e5c..32324686f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_ZeroOrder.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_ZeroOrder.py @@ -1,14 +1,18 @@ -"""D 노선분석 지도 0차곡 겹 — 지형 추정 골짜기 머리(PLAN 584-1 · 608-2 · 요구 443 · 486). +"""D 노선분석 지도 0차곡 겹 — 지형 추정 골짜기 머리(PLAN 584-1 · 608-2 · 616 · 요구 443 · 497). 0차곡 = 뚜렷한 유로가 없는 곡두의 집수지형(`노선_배치.md:31` · 교본 `3. 임도망의 배치 절차.md:156`) -→ 사업 DEM(평활 안 한 `models_plan/dtm_sheet_<방법>.npz`)을 2 m 로 솎아 D8 흐름누적 · +→ 공용 지형 창고 2 m 격자에서 D8 흐름누적 · 위 집수 넓이가 `min_m2` 이상 · `max_m2` 미만인 칸 = 0차곡(그보다 크면 1차 계류) → 세선화 · 선 뽑기(B05 `_mask_to_polylines`) · 경위도 선 + 가닥 · 길이 합. 임계값 = 지식DB 수치 없음 → 정함(브레인) 0.1 ha · 1 ha · 바꿈 = 요청 `min_m2` · `max_m2`. -파생 저장본(열쇠 = 격자 지문 · 임계 · 칸 · 쓴 도엽 누적 지문). -흐름누적 = 공용 지형 창고 도엽마다(도엽 + 여유 200 m · 창고에 있는 이웃 도엽만) 한 번 셈 → -`storage/_shared/terrain/<도엽>/zero_acc__<칸>m.npz`(열쇠 = 쓴 도엽 지형 열쇠) · -임계 바꿈 = 저장 누적에서 칸 고르기 · 선 뽑기만 · 사업 DEM 이 창고 격자가 아니면(옛 받기) 통째 셈. +흐름누적 = 창고 도엽마다(도엽 + 여유 200 m · 창고에 있는 이웃 도엽만) 한 번 셈 → +`storage/_shared/terrain/<도엽>/zero_acc__<칸>m.npz`(열쇠 = 쓴 도엽 지형 열쇠). +기본 임계 선 = 도엽마다 미리 뽑아 창고에(`zero_lines__<칸>m.npz` · 도엽 + 여유 200 m 에서 뽑아 +끝점(작은 쪽)이 그 도엽 안인 선만 · 단순화 끝) → 요청 = 도엽 선 이어 붙여 받은 범위로 자름(616-2). +다른 임계 = 받은 범위 도엽 누적 이어 붙여 칸 고르기 · 선 뽑기(616-1 · 사업 DEM 격자 안 씀). +다른 임계만 파생 저장본(열쇠 = 받은 범위 · 임계 · 칸 · 쓴 도엽 누적 지문). +무거운 셈(창고에 없는 지형 · 누적 · 선 · 다른 임계 첫 셈) = +서버 본체 밖 일꾼 하나(616-3 · `heavy_pool`). 계산 자리 = ③ 서버 단독. """ @@ -16,7 +20,14 @@ from __future__ import annotations import json +import logging import math +import os +import sys +import threading +import time +import types +from concurrent.futures import ProcessPoolExecutor from pathlib import Path from typing import Any @@ -33,13 +44,17 @@ from common_util.common_util_derived_store import ( from common_util.common_util_json import atomic_write_json from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD +logger = logging.getLogger(__name__) + ZERO_ORDER_KIND = "plan_zero_order" -ZERO_ORDER_VERSION = 2 +ZERO_ORDER_VERSION = 3 # 창고 격자 · 도엽 선(616) +ACC_VERSION = 2 # 창고 누적 열쇠 판 — 올리면 전국 누적을 다시 셈 +LINES_VERSION = 1 # 창고 도엽 선 열쇠 판 ZERO_ORDER_MIN_M2 = 1_000.0 # 집수 시작(0.1 ha) ZERO_ORDER_MAX_M2 = 10_000.0 # 이 넓이부터 1차 계류(1 ha) -ZERO_ORDER_CELL_M = 2.0 # 흐름누적 칸(1 m 격자는 솎음) +ZERO_ORDER_CELL_M = 2.0 # 흐름누적 칸 ZERO_ORDER_MIN_LEN_M = 10.0 # 세선화 갈림목 토막 거름 -ZERO_ORDER_MARGIN_M = 200.0 # 도엽 누적 여유(통째 셈과 0차곡 칸 차 0 · 분석 486 3-1) +ZERO_ORDER_MARGIN_M = 200.0 # 도엽 누적 · 선 여유(통째 셈과 0차곡 칸 차 0 · 분석 486 3-1) ACC_CAP = 65535 # 저장 누적 상한(칸 수 · 2 m 면 26 ha · 임계 1 ha 보다 넉넉) SIMPLIFY_M = 1.0 @@ -159,6 +174,13 @@ def zero_order_lines( return mask_lines(d8_accumulation(z, valid), z, valid, x, y, cell_m, min_m2, max_m2) +class NeedsCompute(Exception): + """`compute=False` 인데 창고에 없거나 낡아 셈이 필요함 — 부르는 쪽이 일꾼으로 넘김.""" + + +Memo = dict[tuple[str, int, float], Any] + + def _read_json(path: Path) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) @@ -166,29 +188,62 @@ def _read_json(path: Path) -> Any: return None -def _acc_names(sheet_no: str, epsg: int, cell_m: float) -> tuple[Path, Path, Path]: +def _store_names(prefix: str, sheet_no: str, epsg: int, cell_m: float) -> tuple[Path, Path, Path]: folder = terrain_store.TERRAIN_ROOT / str(sheet_no) - stem = f"zero_acc_{int(epsg)}_{cell_m:g}m" + stem = f"{prefix}_{int(epsg)}_{cell_m:g}m" return folder / f"{stem}.npz", folder / f"{stem}.key.json", folder / f".{stem}.lock" -def _dem_key(sheet_no: str, epsg: int, cell_m: float) -> dict[str, Any] | None: - """창고 지형 열쇠(셈 시간 뺌) — 안 셌거나 원천이 바뀌었으면 None.""" +def _acc_names(sheet_no: str, epsg: int, cell_m: float) -> tuple[Path, Path, Path]: + return _store_names("zero_acc", sheet_no, epsg, cell_m) + + +def _lines_names(sheet_no: str, epsg: int, cell_m: float) -> tuple[Path, Path, Path]: + return _store_names("zero_lines", sheet_no, epsg, cell_m) + + +def _dem_key(sheet_no: str, epsg: int, cell_m: float, memo: Memo | None = None) -> dict | None: + """창고 지형 열쇠(셈 시간 뺌) — 안 셌거나 원천이 바뀌었으면 None · `memo` = 한 요청 안.""" + slot = (str(sheet_no), int(epsg), float(cell_m)) + if memo is not None and slot in memo: + return memo[slot] stored = terrain_store._stored_ok(sheet_no, epsg, cell_m) - return None if stored is None else {k: v for k, v in stored.items() if k != "seconds"} + key = None if stored is None else {k: v for k, v in stored.items() if k != "seconds"} + if memo is not None: + memo[slot] = key + return key -def sheet_accumulation(sheet_no: str, epsg: int, cell_m: float = ZERO_ORDER_CELL_M) -> Path | None: - """도엽 하나의 흐름누적(창고) — 도엽 + 여유 200 m 지형(창고에 있는 도엽만 · 없는 이웃 = - 출구)으로 셈 · 도엽 칸만 저장(uint16 · `ACC_CAP` 로 자름) · 열쇠 = 쓴 도엽 지형 열쇠 · - 지형이 없으면 None.""" - own = _dem_key(sheet_no, epsg, cell_m) - dem_npz = terrain_store._names(sheet_no, epsg, cell_m)[0] - if own is None or own.get("empty") or not dem_npz.is_file(): - return None - with np.load(dem_npz) as data: +def _has_terrain( + sheet_no: str, epsg: int, cell_m: float, compute: bool = True, memo: Memo | None = None +) -> bool: + """창고 지형이 있음(없으면 셈) — 빈 도엽 · 받기 실패 = False.""" + dem = _dem_key(sheet_no, epsg, cell_m, memo) + if dem is None: + if not compute: + raise NeedsCompute(sheet_no) + try: + terrain_store.ensure_sheet_terrain(sheet_no, epsg, cell_m) + except RuntimeError as error: + logger.warning("0차곡 지형 없음 %s: %s", sheet_no, error) + return False + if memo is not None: + memo.pop((str(sheet_no), int(epsg), float(cell_m)), None) + dem = _dem_key(sheet_no, epsg, cell_m, memo) + return dem is not None and not dem.get("empty") + + +def _sheet_frame(path: Path, cell_m: float) -> tuple[int, int, int, int, tuple[float, ...]]: + """창고 도엽 파일(지형 · 누적) → (ix0, iy0, 행, 열, 여유 200 m 상자) — 큰 칸은 머리만 읽음.""" + import zipfile + + with np.load(path) as data: ix0, iy0 = int(data["ix0"]), int(data["iy0"]) - rows, cols = data["z"].shape + name = "z.npy" if "z" in data.files else "acc.npy" + with zipfile.ZipFile(path) as archive, archive.open(name) as head: + version = np.lib.format.read_magic(head) + read = np.lib.format.read_array_header_1_0 + rows, cols = (read if version == (1, 0) else np.lib.format.read_array_header_2_0)(head)[0] pad = math.ceil(ZERO_ORDER_MARGIN_M / cell_m) box = ( (ix0 - pad) * cell_m, @@ -196,15 +251,36 @@ def sheet_accumulation(sheet_no: str, epsg: int, cell_m: float = ZERO_ORDER_CELL (ix0 + cols + pad) * cell_m, (iy0 + rows + pad) * cell_m, ) + return ix0, iy0, rows, cols, box + + +def sheet_accumulation( + sheet_no: str, + epsg: int, + cell_m: float = ZERO_ORDER_CELL_M, + compute: bool = True, + memo: Memo | None = None, +) -> Path | None: + """도엽 하나의 흐름누적(창고) — 도엽 + 여유 200 m 지형(창고에 있는 도엽만 · 없는 이웃 = + 출구)으로 셈 · 도엽 칸만 저장(uint16 · `ACC_CAP` 로 자름) · 열쇠 = 쓴 도엽 지형 열쇠 · + 지형이 없으면 None.""" + own = _dem_key(sheet_no, epsg, cell_m, memo) + dem_npz = terrain_store._names(sheet_no, epsg, cell_m)[0] + if own is None or own.get("empty") or not dem_npz.is_file(): + return None + ix0, iy0, rows, cols, box = _sheet_frame(dem_npz, cell_m) + pad = math.ceil(ZERO_ORDER_MARGIN_M / cell_m) used = {} for no in terrain_store.sheets_for_box(box, epsg): - dem = _dem_key(no, epsg, cell_m) + dem = _dem_key(no, epsg, cell_m, memo) if dem is not None and not dem.get("empty"): used[no] = dem - key = {"version": ZERO_ORDER_VERSION, "margin_m": ZERO_ORDER_MARGIN_M, "dem": used} + key = {"version": ACC_VERSION, "margin_m": ZERO_ORDER_MARGIN_M, "dem": used} npz, key_path, lock_path = _acc_names(sheet_no, epsg, cell_m) if npz.is_file() and _read_json(key_path) == key: return npz + if not compute: + raise NeedsCompute(sheet_no) with terrain_store._Lock(lock_path): if npz.is_file() and _read_json(key_path) == key: # 기다리는 동안 남이 셌을 수 있음 return npz @@ -222,30 +298,10 @@ def sheet_accumulation(sheet_no: str, epsg: int, cell_m: float = ZERO_ORDER_CELL return npz -def store_accumulation_paths( - x: np.ndarray, y: np.ndarray, epsg: int, cell_m: float -) -> list[Path] | None: - """사업 DEM 격자(창고 격자 위)를 덮는 도엽 누적 — 창고 격자 아님 · 지형 안 센 도엽 = None.""" - fx, fy = x[0] / cell_m - 0.5, y[0] / cell_m - 0.5 - if cell_m != ZERO_ORDER_CELL_M or abs(fx - round(fx)) > 1e-6 or abs(fy - round(fy)) > 1e-6: - return None - half = cell_m / 2 - box = (x[0] - half, y[0] - half, x[-1] + half, y[-1] + half) - paths: list[Path] = [] - for no in terrain_store.sheets_for_box(box, epsg): - dem = _dem_key(no, epsg, cell_m) - if dem is None: - return None - path = None if dem.get("empty") else sheet_accumulation(no, epsg, cell_m) - if path is not None: - paths.append(path) - return paths - - def paste_accumulation( paths: list[Path], x: np.ndarray, y: np.ndarray, cell_m: float ) -> np.ndarray: - """도엽 누적을 사업 격자(y 오름차순)에 이어 붙임 — 없는 칸 0.""" + """도엽 누적을 창고 격자(y 오름차순)에 이어 붙임 — 없는 칸 0 · 겹친 칸은 뒤 파일.""" ix0, iy0 = round(x[0] / cell_m - 0.5), round(y[0] / cell_m - 0.5) acc = np.zeros((len(y), len(x))) for path in paths: @@ -259,69 +315,251 @@ def paste_accumulation( return acc +def _box_lines( + acc_paths: list[Path], + epsg: int, + box: tuple[float, ...], + cell_m: float, + min_m2: float, + max_m2: float, +) -> list[Any]: + """누적 이어 붙여 상자 안 창고 격자에서 선 뽑기 → 단순화한 shapely 선(작업 좌표계 m).""" + import shapely + + sheets = [p.parent.name for p in acc_paths] + grid = terrain_store.load_terrain(epsg, sheets=sheets, box=box, cell_m=cell_m) + x, y = grid["x"], grid["y"] + if not len(x) or not len(y): + return [] + acc = paste_accumulation(acc_paths, x, y, cell_m) + lines = mask_lines(acc, grid["z"], grid["valid_mask"], x, y, cell_m, min_m2, max_m2) + if not lines: + return [] + index = np.repeat(np.arange(len(lines)), [len(line) for line in lines]) + geoms = shapely.linestrings(np.concatenate(lines), indices=index) + return list(shapely.simplify(geoms, SIMPLIFY_M)) + + +def _owned_lines(lines: list[Any], sheet_no: str, epsg: int) -> list[Any]: + """끝점 둘 중 작은 쪽(x, y 차례)이 그 도엽(경위도) 안인 선 — 이웃과 겹쳐 뽑힌 선은 한 곳만.""" + import shapely + from pyproj import Transformer + + from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_to_bounds + + if not lines: + return [] + first = shapely.get_coordinates(shapely.get_point(lines, 0)) + last = shapely.get_coordinates(shapely.get_point(lines, -1)) + swap = (last[:, 0] < first[:, 0]) | ((last[:, 0] == first[:, 0]) & (last[:, 1] < first[:, 1])) + ends = np.where(swap[:, None], last, first) + lon, lat = Transformer.from_crs( + terrain_store._crs(epsg), "EPSG:4326", always_xy=True + ).transform(ends[:, 0], ends[:, 1]) + lon0, lat0, lon1, lat1 = sheet5k_to_bounds(sheet_no) + keep = (lon >= lon0) & (lon < lon1) & (lat >= lat0) & (lat < lat1) + return [g for g, k in zip(lines, keep) if k] + + +def sheet_lines( + sheet_no: str, + epsg: int, + cell_m: float = ZERO_ORDER_CELL_M, + compute: bool = True, + memo: Memo | None = None, +) -> Path | None: + """도엽 하나의 기본 임계 0차곡 선(창고 · 616-2) — 도엽 + 여유 200 m 에서 뽑아(창고에 있는 이웃 + 누적만) 끝점이 그 도엽 안인 선만 · 단순화 끝 · 열쇠 = 쓴 누적 지문 · 지형 없으면 None.""" + import shapely + + own = sheet_accumulation(sheet_no, epsg, cell_m, compute, memo) + if own is None: + return None + box = _sheet_frame(own, cell_m)[4] + used = {} + for no in terrain_store.sheets_for_box(box, epsg): + path = own if no == sheet_no else _acc_names(no, epsg, cell_m)[0] + if (stamp := file_stamp(path)) is not None: + used[no] = stamp + key = { + "version": LINES_VERSION, + "min_m2": ZERO_ORDER_MIN_M2, + "max_m2": ZERO_ORDER_MAX_M2, + "min_len_m": ZERO_ORDER_MIN_LEN_M, + "simplify_m": SIMPLIFY_M, + "margin_m": ZERO_ORDER_MARGIN_M, + "acc": used, + } + npz, key_path, lock_path = _lines_names(sheet_no, epsg, cell_m) + if npz.is_file() and _read_json(key_path) == key: + return npz + if not compute: + raise NeedsCompute(sheet_no) + with terrain_store._Lock(lock_path): + if npz.is_file() and _read_json(key_path) == key: # 기다리는 동안 남이 셌을 수 있음 + return npz + paths = [_acc_names(no, epsg, cell_m)[0] for no in sorted(used)] + lines = _box_lines(paths, epsg, box, cell_m, ZERO_ORDER_MIN_M2, ZERO_ORDER_MAX_M2) + mine = _owned_lines(lines, sheet_no, epsg) + coords, index = shapely.get_coordinates(mine, return_index=True) + key_path.unlink(missing_ok=True) # 쓰는 도중 끊기면 옛 열쇠가 새 파일을 덮지 않게 + atomic_write_npz(npz, coords=coords, index=index.astype(np.int32)) + atomic_write_json(key_path, key) + return npz + + +def _read_lines(path: Path) -> list[Any]: + import shapely + + with np.load(path) as data: + coords, index = data["coords"], data["index"] + return list(shapely.linestrings(coords, indices=index)) if len(coords) else [] + + +def _project_box(npz: Path) -> tuple[float, float, float, float]: + """사업 DEM 범위(칸 모서리) — 창고 격자에서 이 상자 안 칸을 씀.""" + with np.load(npz) as dem: + x, y = np.asarray(dem["x"], float), np.asarray(dem["y"], float) + cell = float(dem["resolution"][0]) if "resolution" in dem.files else float(x[1] - x[0]) + half = cell / 2 + return (x[0] - half, y[0] - half, x[-1] + half, y[-1] + half) + + +def _collection(shapes: list[Any], epsg: int) -> dict[str, Any]: + """작업 좌표계 선 → 경위도 GeoJSON(좌표 변환 한 번).""" + import shapely + from pyproj import Transformer + + features: list[dict[str, Any]] = [] + if shapes: + coords, index = shapely.get_coordinates(shapes, return_index=True) + lon, lat = Transformer.from_crs( + terrain_store._crs(epsg), "EPSG:4326", always_xy=True + ).transform(coords[:, 0], coords[:, 1]) + points = np.column_stack([lon, lat]) + for part in np.split(points, np.flatnonzero(np.diff(index)) + 1): + geometry = {"type": "LineString", "coordinates": part.tolist()} + features.append({"type": "Feature", "geometry": geometry, "properties": {}}) + return {"type": "FeatureCollection", "features": features} + + def zero_order_layer( project_root: Path, models_dir: Path, epsg: int, min_m2: float = ZERO_ORDER_MIN_M2, max_m2: float = ZERO_ORDER_MAX_M2, -) -> dict[str, Any]: - """0차곡 겹 — 지문(격자 파일 · 임계 · 칸 · 도엽 누적) 같으면 저장본 · 경위도 선 + 가닥 · - 길이 합 m.""" - from shapely.geometry import LineString, mapping - from shapely.ops import transform - - from D03_PlanAnalysis.D03_PlanAnalysis_Layers import _transforms - + compute: bool = True, +) -> dict[str, Any] | None: + """0차곡 겹 — 받은 범위의 창고 도엽 선(기본 임계) 또는 누적(다른 임계 · 지문 같으면 저장본) · + 경위도 선 + 가닥 · 길이 합 m. `compute=False` = 셈이 필요하면 None(일꾼으로 넘김).""" npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" - stamp = file_stamp(npz) - if stamp is None: + if file_stamp(npz) is None: raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.") - with np.load(npz) as dem: - x, y = np.asarray(dem["x"], float), np.asarray(dem["y"], float) - cell = float(dem["resolution"][0]) if "resolution" in dem.files else float(x[1] - x[0]) - step = max(1, round(ZERO_ORDER_CELL_M / cell)) - x, y, cell_m = x[::step], y[::step], cell * step - paths = store_accumulation_paths(x, y, epsg, cell_m) - key = derived_key( - ZERO_ORDER_KIND, - ZERO_ORDER_VERSION, - npz.name, - stamp, - int(epsg), - float(min_m2), - float(max_m2), - ZERO_ORDER_CELL_M, - ZERO_ORDER_MIN_LEN_M, - SIMPLIFY_M, - None if paths is None else [f"{p.parent.name}:{file_stamp(p)}" for p in paths], - ) - saved = read_derived(project_root, ZERO_ORDER_KIND, key) - if saved is not None: - return saved["value"] - with np.load(npz) as dem: - z = np.asarray(dem["z"], float)[::step, ::step] - valid = np.asarray(dem["valid_mask"], bool)[::step, ::step] - if paths is None: - lines = zero_order_lines(z, valid, x, y, cell_m, min_m2, max_m2) - else: - acc = paste_accumulation(paths, x, y, cell_m) - lines = mask_lines(acc, z, valid, x, y, cell_m, min_m2, max_m2) - _, to_ll = _transforms(epsg) - shapes = [LineString(line).simplify(SIMPLIFY_M) for line in lines] - value = { - "min_m2": float(min_m2), - "max_m2": float(max_m2), - "cell_m": cell_m, - "count": len(shapes), - "length_m": round(sum(s.length for s in shapes), 1), - "collection": { - "type": "FeatureCollection", - "features": [ - {"type": "Feature", "geometry": mapping(transform(to_ll, s)), "properties": {}} - for s in shapes - ], - }, - } + box = _project_box(npz) + cell_m = ZERO_ORDER_CELL_M + default = (float(min_m2), float(max_m2)) == (ZERO_ORDER_MIN_M2, ZERO_ORDER_MAX_M2) + memo: Memo = {} + try: + paths: dict[str, Path] = {} + for no in terrain_store.sheets_for_box(box, epsg): + if _has_terrain(no, epsg, cell_m, compute, memo): + if (got := sheet_accumulation(no, epsg, cell_m, compute, memo)) is not None: + paths[no] = got + if default: # 누적을 모두 갖춘 뒤 선(이웃 누적이 선 열쇠에 듦) + for no in list(paths): + paths[no] = sheet_lines( + no, epsg, cell_m, compute, memo + ) # 창고 도엽 선 = 저장본 · 이어 붙여 자르기만(파생 저장본 안 씀) + return _layer_value(_clipped_lines(list(paths.values()), box), epsg, min_m2, max_m2) + key = derived_key( + ZERO_ORDER_KIND, + ZERO_ORDER_VERSION, + [round(v, 3) for v in box], + int(epsg), + float(min_m2), + float(max_m2), + cell_m, + ZERO_ORDER_MIN_LEN_M, + SIMPLIFY_M, + [f"{p.name}:{no}:{file_stamp(p)}" for no, p in sorted(paths.items())], + ) + saved = read_derived(project_root, ZERO_ORDER_KIND, key) + if saved is not None: + return saved["value"] + if not compute: + raise NeedsCompute("임계") + except NeedsCompute: + return None + shapes = _box_lines([paths[no] for no in sorted(paths)], epsg, box, cell_m, min_m2, max_m2) + value = _layer_value(shapes, epsg, min_m2, max_m2) write_derived(project_root, ZERO_ORDER_KIND, key, value) return value + + +def _clipped_lines(paths: list[Path], box: tuple[float, ...]) -> list[Any]: + """도엽 선 이어 붙여 받은 범위로 자름 — 잘려 짧아진 토막(`ZERO_ORDER_MIN_LEN_M` 미만) 거름.""" + import shapely + + lines = [g for p in paths for g in _read_lines(p)] + if not lines: + return [] + parts = shapely.get_parts(shapely.clip_by_rect(lines, *box)) + keep = (shapely.get_type_id(parts) == 1) & (shapely.length(parts) >= ZERO_ORDER_MIN_LEN_M) + return list(parts[keep]) + + +def _layer_value(shapes: list[Any], epsg: int, min_m2: float, max_m2: float) -> dict[str, Any]: + import shapely + + return { + "min_m2": float(min_m2), + "max_m2": float(max_m2), + "cell_m": ZERO_ORDER_CELL_M, + "count": len(shapes), + "length_m": round(float(shapely.length(shapes).sum()), 1) if shapes else 0.0, + "collection": _collection(shapes, epsg), + } + + +# ── 무거운 셈 일꾼(616-3) ───────────────────────────────────────────── + +_POOL: ProcessPoolExecutor | None = None + + +def _watch_parent(pid: int) -> None: + """일꾼 시작 — 서버 프로세스가 끝나면(다시 켬 · 강제 종료) 따라 끝남(고아 일꾼 막음).""" + import psutil + + parent = psutil.Process(pid) + + def loop() -> None: + while parent.is_running(): + time.sleep(2.0) + os._exit(0) + + threading.Thread(target=loop, daemon=True).start() + + +def _warm() -> None: + """일꾼 띄울 때 무거운 모듈을 미리 읽음(첫 셈이 읽기를 안 기다리게).""" + import shapely # noqa: F401 + import skimage.morphology # noqa: F401 + + from B05_Profile import B05_Profile_Engine_Skeleton # noqa: F401 + + +def heavy_pool() -> ProcessPoolExecutor: + """서버 본체 밖 일꾼 하나 — 처음 부를 때 띄워 둠 · 죽었으면 새로(이벤트 루프 실에서만 부름).""" + global _POOL + if _POOL is None or getattr(_POOL, "_broken", False): + _POOL = ProcessPoolExecutor(1, initializer=_watch_parent, initargs=(os.getpid(),)) + # ponytail: spawn 일꾼이 main.py(라우터 전부 · 약 10 s)를 다시 읽지 않게 띄우는 순간만 + # 빈 __main__ 으로 바꿈 — 일꾼은 이 모듈만 읽음 · 파이썬 판이 바뀌어 안 먹히면 느려질 뿐 + main = sys.modules["__main__"] + sys.modules["__main__"] = types.ModuleType("__main__") + try: + _POOL.submit(_warm) + finally: + sys.modules["__main__"] = main + return _POOL diff --git a/resources/tester/test_584_1_plan_geo_lines.py b/resources/tester/test_584_1_plan_geo_lines.py index 529380914..bb9ae2cf1 100644 --- a/resources/tester/test_584_1_plan_geo_lines.py +++ b/resources/tester/test_584_1_plan_geo_lines.py @@ -182,31 +182,6 @@ def test_zero_order_lines_head_only() -> None: assert sum(math.dist(a, b) for a, b in zip(line, line[1:])) >= zo.ZERO_ORDER_MIN_LEN_M -def test_zero_order_layer_saved_and_threshold_key(tmp_path, monkeypatch) -> None: - n = 60 - models = tmp_path / "models" - models.mkdir() - np.savez( - models / f"dtm_sheet_{zo.SHEET_SURFACE_DEFAULT_METHOD}.npz", - x=230000.0 + np.arange(n) * 2.0, - y=450000.0 + np.arange(n) * 2.0, - z=_valley(n), - valid_mask=np.ones((n, n), bool), - resolution=np.array([2.0, 2.0]), - ) - calls = [] - real = zo.zero_order_lines - monkeypatch.setattr(zo, "zero_order_lines", lambda *a, **k: calls.append(1) or real(*a, **k)) - first = zo.zero_order_layer(tmp_path, models, EPSG, 400.0, 2000.0) - assert first["count"] >= 1 and first["length_m"] > 0 and first["cell_m"] == 2.0 - assert first["collection"]["features"][0]["geometry"]["type"] == "LineString" - again = zo.zero_order_layer(tmp_path, models, EPSG, 400.0, 2000.0) - assert (again["count"], again["length_m"]) == (first["count"], first["length_m"]) - assert len(calls) == 1 # 두 번째 = 저장본 - zo.zero_order_layer(tmp_path, models, EPSG, 400.0, 3000.0) # 임계 바뀜 = 다시 셈 - assert len(calls) == 2 - - def test_zero_order_no_dem(tmp_path) -> None: with pytest.raises(FileNotFoundError): zo.zero_order_layer(tmp_path, tmp_path, EPSG) diff --git a/resources/tester/test_608_plan_zero_order_store.py b/resources/tester/test_608_plan_zero_order_store.py index c399e4b0a..6f242bced 100644 --- a/resources/tester/test_608_plan_zero_order_store.py +++ b/resources/tester/test_608_plan_zero_order_store.py @@ -1,5 +1,5 @@ """D 0차곡 흐름누적 = 도엽별 공용 창고(PLAN 608-2) — 도엽 + 여유 200 m 조각 = 통째 셈 · -처음만 셈 · 이웃 도엽 지형이 새로 생기면 다시 · 임계 바꿈은 누적 그대로 · 창고 격자 아닌 DEM = 통째. +처음만 셈 · 이웃 도엽 지형이 새로 생기면 다시 · 임계 바꿈은 누적 그대로(사업 DEM 격자 무관 = 616). ./venv/Scripts/python.exe -m pytest resources/tester/test_608_plan_zero_order_store.py -q """ @@ -77,7 +77,7 @@ def test_tiles_equal_whole_and_saved(shared) -> None: def test_layer_uses_store_and_threshold_reuses(shared, tmp_path, monkeypatch) -> None: - """사업 DEM 이 창고 격자 위면 도엽 누적을 이어 붙임 · 임계 바꿈 = 누적 다시 안 셈.""" + """받은 범위 도엽 누적을 이어 붙임 · 임계 바꿈 = 누적 다시 안 셈.""" calls = shared monkeypatch.setattr(zo, "ZERO_ORDER_CELL_M", CELL) _put_sheet(WEST) @@ -100,5 +100,3 @@ def test_layer_uses_store_and_threshold_reuses(shared, tmp_path, monkeypatch) -> assert len(calls) == 2 # 도엽 둘 한 번씩 zo.zero_order_layer(tmp_path, models, EPSG, 1 * cells, 10 * cells) assert len(calls) == 2 # 임계 바꿈 = 저장 누적에서 칸 고르기만 - # 창고 격자에서 반 칸 어긋난 DEM = 통째 셈 - assert zo.store_accumulation_paths(x + CELL / 2, y, EPSG, CELL) is None diff --git a/resources/tester/test_616_zero_order_fast.py b/resources/tester/test_616_zero_order_fast.py new file mode 100644 index 000000000..3ecf7f68c --- /dev/null +++ b/resources/tester/test_616_zero_order_fast.py @@ -0,0 +1,189 @@ +"""PLAN 616 — 0차곡 첫 계산 빠르게: 창고 격자에서 셈(616-1) · 도엽 선 미리 뽑아 이어 붙임(616-2) · +무거운 셈 = 서버 밖 일꾼(616-3) · 급경사 값 그림 바이너리(616-4). + +./venv/Scripts/python.exe -m pytest resources/tester/test_616_zero_order_fast.py -q +""" + +from __future__ import annotations + +import base64 +import os +import sys +from pathlib import Path + +import numpy as np +import pytest +import shapely + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from common_util import common_util_terrain_store as store # noqa: E402 +from common_util.common_util_atomic import atomic_write_npz # noqa: E402 +from common_util.common_util_json import atomic_write_json # noqa: E402 +from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers # noqa: E402 +from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zo # noqa: E402 +from tools import precompute_terrain as tool # noqa: E402 + +EPSG = 5187 +WEST, EAST = "36901074", "36901075" +CELL = 10.0 # 시험은 굵은 칸(여유 200 m = 20 칸 · 기본 임계 = 10 ~ 100 칸) + + +def _height(x, y): + """기운 면 + 골 · 마루 — 웅덩이 없음 · 작은 집수.""" + return 0.08 * x + 6.0 * np.sin(y / 38.0) + 2.0 * np.sin(x / 55.0) + + +def _put_sheet(no: str) -> None: + ix0, iy0, own = store._owned(no, EPSG, CELL) + rows, cols = own.shape + gx, gy = np.meshgrid((np.arange(cols) + ix0 + 0.5) * CELL, (np.arange(rows) + iy0 + 0.5) * CELL) + z = _height(gx - 300000.0, gy).astype(np.float32) + z[~own] = np.nan + npz, key_path, _ = store._names(no, EPSG, CELL) + atomic_write_npz(npz, z=z, ix0=np.int64(ix0), iy0=np.int64(iy0), cell_m=np.float64(CELL)) + atomic_write_json(key_path, {**store._key(EPSG, CELL, {}), "source": "gpkg", "seconds": 1}) + + +@pytest.fixture +def shared(tmp_path, monkeypatch): + """임시 창고 · 칸 10 m · d8 부름 셈.""" + monkeypatch.setattr(store, "TERRAIN_ROOT", tmp_path / "terrain") + monkeypatch.setattr(zo, "ZERO_ORDER_CELL_M", CELL) + calls = [] + real = zo.d8_accumulation + monkeypatch.setattr(zo, "d8_accumulation", lambda *a: calls.append(1) or real(*a)) + return calls + + +def _models(tmp_path: Path, shift: float = 0.0) -> Path: + """두 도엽 가운데를 덮는 사업 DEM(칸 = 시험 칸 · 값은 안 씀 · 범위만).""" + grid = store.load_terrain(EPSG, sheets=[WEST, EAST], cell_m=CELL) + x, y = grid["x"][30:-30] + shift, grid["y"][30:-30] + models = tmp_path / f"models{shift:g}" + models.mkdir() + np.savez( + models / f"dtm_sheet_{zo.SHEET_SURFACE_DEFAULT_METHOD}.npz", + x=x, + y=y, + z=np.zeros((len(y), len(x))), + valid_mask=np.ones((len(y), len(x)), bool), + resolution=np.array([CELL], np.float32), + ) + return models + + +def test_sheet_lines_join_equals_box(shared, tmp_path) -> None: + """도엽 선(여유 200 m 에서 뽑아 끝점 도엽만) 이어 붙임 = 두 도엽 통째 상자에서 뽑은 선.""" + _put_sheet(WEST) + _put_sheet(EAST) + paths = [zo.sheet_lines(no, EPSG, CELL) for no in (WEST, EAST)] + joined = [g for p in paths for g in zo._read_lines(p)] + grid = store.load_terrain(EPSG, sheets=[WEST, EAST], cell_m=CELL) + half = CELL / 2 + box = (grid["x"][0] - half, grid["y"][0] - half, grid["x"][-1] + half, grid["y"][-1] + half) + accs = [zo._acc_names(no, EPSG, CELL)[0] for no in (WEST, EAST)] + whole = zo._box_lines(accs, EPSG, box, CELL, zo.ZERO_ORDER_MIN_M2, zo.ZERO_ORDER_MAX_M2) + assert len(whole) >= 20 + # 여유 200 m 보다 긴 선(시험 지형 = 동서로 긴 골)만 창 끝에서 잘려 다를 수 있음 — 1 % 안 + assert abs(len(joined) - len(whole)) <= 0.01 * len(whole) + total = sum(g.length for g in whole) + assert abs(sum(g.length for g in joined) - total) <= 0.01 * total + keys = {tuple(map(tuple, shapely.get_coordinates(g).round(3))) for g in joined} + assert len(keys) == len(joined) # 겹쳐 뽑힌 선은 한 도엽에만 + + +def test_layer_default_reads_lines_light(shared, tmp_path) -> None: + """기본 임계 = 창고 도엽 선 이어 붙여 자름 — 창고에 없으면 compute=False 가 None(일꾼 몫) · + 있으면 d8 · 선 뽑기 없이 바로.""" + calls = shared + _put_sheet(WEST) + _put_sheet(EAST) + models = _models(tmp_path) + assert zo.zero_order_layer(tmp_path, models, EPSG, compute=False) is None + assert calls == [] + first = zo.zero_order_layer(tmp_path, models, EPSG) + assert first["count"] >= 10 and first["cell_m"] == CELL + assert len(calls) == 2 # 도엽 둘 누적 한 번씩 + light = zo.zero_order_layer(tmp_path, models, EPSG, compute=False) + assert (light["count"], light["length_m"]) == (first["count"], first["length_m"]) + assert len(calls) == 2 + assert light["collection"]["features"][0]["geometry"]["type"] == "LineString" + # 받은 범위 밖 선은 잘림 — 모든 점이 범위 안 + with np.load(models / f"dtm_sheet_{zo.SHEET_SURFACE_DEFAULT_METHOD}.npz") as dem: + x0, x1 = dem["x"][0] - CELL / 2, dem["x"][-1] + CELL / 2 + from pyproj import Transformer + + to_m = Transformer.from_crs("EPSG:4326", f"EPSG:{EPSG}", always_xy=True) + pts = np.array( + [p for f in light["collection"]["features"] for p in f["geometry"]["coordinates"]] + ) + mx, _ = to_m.transform(pts[:, 0], pts[:, 1]) + assert mx.min() >= x0 - 0.01 and mx.max() <= x1 + 0.01 + + +def test_layer_custom_threshold_saved_and_grid_free(shared, tmp_path) -> None: + """다른 임계 = 창고 누적에서 셈(사업 DEM 격자 안 씀 · 반 칸 어긋나도 창고) · 둘째 = 저장본.""" + calls = shared + _put_sheet(WEST) + _put_sheet(EAST) + models = _models(tmp_path) + cells = CELL * CELL + assert zo.zero_order_layer(tmp_path, models, EPSG, 5 * cells, 40 * cells, False) is None + first = zo.zero_order_layer(tmp_path, models, EPSG, 5 * cells, 40 * cells) + assert first["count"] >= 1 and len(calls) == 2 + again = zo.zero_order_layer(tmp_path, models, EPSG, 5 * cells, 40 * cells, False) + assert again == first # 저장본 + shifted = zo.zero_order_layer( + tmp_path, _models(tmp_path, CELL / 2), EPSG, 5 * cells, 40 * cells + ) + assert shifted["count"] >= 1 and len(calls) == 2 # 옛 격자 갈래 없음 = 누적 다시 안 셈 + + +def test_lines_recomputed_when_neighbor_acc_appears(shared) -> None: + _put_sheet(WEST) + _put_sheet(EAST) + path = zo.sheet_lines(WEST, EPSG, CELL) # 동쪽 누적 없음 = 빼고 셈 + stamp = path.stat().st_mtime_ns + assert zo.sheet_lines(WEST, EPSG, CELL, compute=False) == path + zo.sheet_accumulation(EAST, EPSG, CELL) + with pytest.raises(zo.NeedsCompute): + zo.sheet_lines(WEST, EPSG, CELL, compute=False) + assert zo.sheet_lines(WEST, EPSG, CELL).stat().st_mtime_ns != stamp + + +def test_acc_key_version_unchanged() -> None: + """누적 열쇠 판을 올리면 전국 미리 계산 누적을 통째 다시 셈 — 616 은 안 올림.""" + assert zo.ACC_VERSION == 2 + + +def test_precompute_zero_lines_step(shared, monkeypatch) -> None: + _put_sheet(WEST) + _put_sheet(EAST) + monkeypatch.setattr(tool, "CELL_M", CELL) + assert "zero_lines" in tool.STEPS and tool.STEPS.index("zero_lines") == 2 + _, _, info = tool.run_step("zero_lines", WEST) + assert info["ok"] and info["fresh"] and not info["empty"] + _, _, again = tool.run_step("zero_lines", WEST) + assert again["ok"] and not again["fresh"] + + +def test_raster_bytes_roundtrip() -> None: + png = b"\x89PNG\r\n\x1a\nfake" + value = {"image": "data:image/png;base64," + base64.b64encode(png).decode(), "counts": [1, 2]} + got, meta = layers.raster_bytes(value) + assert got == png and meta == {"counts": [1, 2]} + + +def test_heavy_pool_runs_outside() -> None: + """일꾼 = 다른 프로세스 · 띄울 때 __main__ 을 되돌려 놓음.""" + main = sys.modules["__main__"] + pool = zo.heavy_pool() + try: + assert sys.modules["__main__"] is main + assert pool.submit(os.getpid).result(timeout=120) != os.getpid() + assert zo.heavy_pool() is pool + finally: + pool.shutdown() + zo._POOL = None diff --git a/tools/precompute_terrain.py b/tools/precompute_terrain.py index b5a78e4a3..2e3b9a8b0 100644 --- a/tools/precompute_terrain.py +++ b/tools/precompute_terrain.py @@ -2,7 +2,8 @@ """전국 미리 계산 일괄 도구(PLAN 612-1 · 요구 493) — 공용 지형 창고에 도엽마다 미리 셈. 실행: ./venv/Scripts/python.exe tools/precompute_terrain.py --sheets 36901 --workers 4 - --steps dem,zero,e07 단계(차례 고정 = 지형 → 0차곡 누적 → E07 · 뒤에 tiles) + --steps dem,zero,zero_lines,e07 단계(차례 고정 = 지형 → 0차곡 누적 → 0차곡 선 → E07 · + 뒤에 tiles · zero_lines = 두 PC zero 단계가 다 끝난 뒤 · 이웃 누적 없으면 그 이웃 빼고 셈) --sheets 36901,36712089 도엽 번호 또는 앞자리(1:50,000 묶음) · 주면 높이 차 거름 안 씀 --box lon0,lat0,lon1,lat1 도엽 가운데가 이 경위도 상자 안 --min-relief 150 도엽 안 높이 차(m) 이상만(산지) · --limit N 앞 N 장만 @@ -14,8 +15,8 @@ - 도엽 목록 = 전국 등고선 파일 꼭짓점이 닿는 도엽(0.025°) — `_precompute_sheets.json`(전국 파일 지문이 같으면 다시 안 셈) · 차례 = 등고선 길이 큰 것(산지)부터 · 같으면 높이 차. - 좌표계 = 도엽 가운데 경도의 TM 띠(`tm_epsg_for_lon` · 5185~5187) · 격자 2 m. -- 셈 = 공용 함수만 부름(창고 지형 · D 0차곡 누적 · E07 도엽) — 저장 열쇠가 같으면 함수가 건너뜀 · - 쓰기 = 함수 쪽 원자 쓰기(끊겨도 반쯤 쓴 파일 없음). +- 셈 = 공용 함수만 부름(창고 지형 · D 0차곡 누적 · 선 · E07 도엽) — 저장 열쇠가 같으면 함수가 + 건너뜀 · 쓰기 = 함수 쪽 원자 쓰기(끊겨도 반쯤 쓴 파일 없음). - 이어 하기 = PC 별 `_precompute_progress_<컴퓨터 이름>.json`(단계 · 도엽마다 성공 · 시간 · 시놀로지가 두 PC 사이 옮김 → 한 파일은 한 PC 만 씀) · 옛 `_precompute_progress.json` 의 성공은 읽어서 이어 받음(안 씀) · 성공한 도엽은 다시 안 부름 · 실패는 다음에 다시 · 전국 파일이 바뀌면 @@ -59,7 +60,7 @@ from common_util import common_util_terrain_store as store # noqa: E402 from common_util.common_util_crs import tm_epsg_for_lon # noqa: E402 from common_util.common_util_json import atomic_write_json # noqa: E402 -STEPS = ("dem", "zero", "e07") +STEPS = ("dem", "zero", "zero_lines", "e07") CELL_M = 2.0 PROGRESS_NAME = "_precompute_progress.json" # 옛 한 벌 기록(읽기만) PC_NAME = platform.node() or "pc" @@ -70,7 +71,12 @@ INDEX_CHUNK = 50_000 # 가닥 묶음(약 0.5 GB 읽기) INDEX_STRIDE = 8 # 꼭짓점 솎음(도엽 2 km 에 비해 넉넉) SAVE_EVERY_S = 10.0 # 단계별 창고 파일 앞머리(크기 셈) -STEP_FILES = {"dem": ("dem_",), "zero": ("zero_acc_",), "e07": ("e07_", "e07si_")} +STEP_FILES = { + "dem": ("dem_",), + "zero": ("zero_acc_",), + "zero_lines": ("zero_lines_",), + "e07": ("e07_", "e07si_"), +} _ENVELOPE = {0: 0, 1: 32, 2: 48, 3: 48, 4: 64} @@ -322,6 +328,13 @@ def run_step(step: str, job: str, part: Part | None = None) -> tuple[str, str, d before = _mtime(target) empty = zero.sheet_accumulation(sheet_no, epsg, CELL_M) is None fresh = _mtime(target) != before + elif step == "zero_lines": + from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero + + target = zero._lines_names(sheet_no, epsg, CELL_M)[0] + before = _mtime(target) + empty = zero.sheet_lines(sheet_no, epsg, CELL_M) is None + fresh = _mtime(target) != before elif step == "e07": from E07_StabArea import E07_StabArea_Router as e07_router from E07_StabArea import E07_StabArea_Sheet as e07 @@ -558,7 +571,7 @@ def summary(sheets: list[str], progress: dict[str, Any]) -> dict[str, Any]: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="공용 지형 창고 전국 미리 계산(PLAN 612)") - parser.add_argument("--steps", default="dem,zero,e07") + parser.add_argument("--steps", default="dem,zero,zero_lines,e07") parser.add_argument("--sheets", default="") parser.add_argument("--box", default="") parser.add_argument("--min-relief", type=float, default=150.0) From 5d6d7bbb839e906650531cf6d5fe3b3eada2d10e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:18:52 +0900 Subject: [PATCH 2/7] =?UTF-8?q?perf(D03):=200=EC=B0=A8=EA=B3=A1=20?= =?UTF-8?q?=EB=AC=B4=EA=B1=B0=EC=9A=B4=20=EC=85=88=20=EC=9D=BC=EA=BE=BC?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EA=B8=89=EA=B2=BD=EC=82=AC=20?= =?UTF-8?q?=EA=B0=92=20=EA=B7=B8=EB=A6=BC=20=EB=B0=94=EC=9D=B4=EB=84=88?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EC=B9=A0=ED=95=98=EA=B8=B0=20toDataURL=20?= =?UTF-8?q?(PLAN=20616-3=20=C2=B7=20616-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 0차곡 길 = 창고 선으로 바로(스레드) · 셈 필요하면 서버 본체 밖 일꾼 · 응답 = json.dumps 바로(큰 선 묶음 jsonable_encoder 건넘) - 급경사 = PNG 바이트 + 머리 X-Plan-Raster(칸 수 · 범위) · Content-Encoding identity(PNG 다시 gzip 안 함) · 음영기복 · 산사태 = JSON 그대로 - Api fetchPlanRaster 급경사만 바이너리(blob 주소 · slopeValues 가 다 읽고 놓음) - 칠하기 = canvas.toDataURL(toBlob 한가 작업 기다림 최대 1 s 없앰) Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 16 ++++++++++-- D03_PlanAnalysis/D03_PlanAnalysis_Layers.py | 6 +++++ D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 26 +++++++++++++------ .../D03_PlanAnalysis_UI_SlopeBands.ts | 14 ++++------ 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 6ebd7774b..0026c5d0c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -212,10 +212,22 @@ export const fetchPlanSheetTile = ( ): Promise => request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`); -export const fetchPlanRaster = ( +/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면 + * `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */ +export const fetchPlanRaster = async ( projectId: string, kind: "slope" | "hillshade" | "landslide", -): Promise => request(`${base(projectId)}/layers/raster?kind=${kind}`); +): Promise => { + const path = `${base(projectId)}/layers/raster?kind=${kind}`; + if (kind !== "slope") return request(path); + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { detail?: unknown }; + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster; + return { ...meta, image: URL.createObjectURL(await response.blob()) }; +}; /** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */ export interface PlanZones extends GeoJsonCollection { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 3cc402d0e..b943400a0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -281,6 +281,12 @@ def slope_layer( return value +def raster_bytes(value: dict[str, Any]) -> tuple[bytes, dict[str, Any]]: + """저장본 값 그림(base64 PNG 글) → (PNG 바이트, 나머지 칸) — 바이너리 전송(616-4).""" + meta = {k: v for k, v in value.items() if k != "image"} + return base64.b64decode(value["image"].split(",", 1)[1]), meta + + def lonlat_corners(box: Any, epsg: int) -> list[list[float]]: """TM 상자 → 경위도 네 귀퉁이(왼쪽 아래부터 돎) — GL 바탕 그림 자리(586).""" from common_util.common_util_crs import tm_to_lonlat diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index a9b501632..d42ce2119 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -399,8 +399,9 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response: @router.get("/{project_id}/plan/layers/raster", response_model=None) -async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]: - """급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · +async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any] | Response: + """급경사 기울기 % 값 그림 + 1 % 칸 수(603 · PNG 바이트 + 머리 `X-Plan-Raster` 616-4) · + 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · 산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본.""" if kind == "landslide": root, epsg, box = await _layer_root(project_id) @@ -422,6 +423,10 @@ async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, ) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error + if kind == "slope": + png, meta = layers.raster_bytes(value) + head = {"X-Plan-Raster": json.dumps({"kind": kind, **meta}), "Content-Encoding": "identity"} + return Response(png, media_type="image/png", headers=head) # PNG = 다시 압축 안 함 return {"status": "success", "kind": kind, **value} @@ -479,18 +484,23 @@ async def plan_layer_zero_order( project_id: UUID, min_m2: float = zero_order.ZERO_ORDER_MIN_M2, max_m2: float = zero_order.ZERO_ORDER_MAX_M2, -) -> dict[str, Any]: - """0차곡 선(지형 추정 · 위 집수 `min_m2` ~ `max_m2`) — 격자 지문 · 임계 같으면 저장본.""" +) -> Response: + """0차곡 선(지형 추정 · 위 집수 `min_m2` ~ `max_m2`) — 창고 도엽 선 · 다른 임계는 저장본 · + 창고에 없어 셈이 필요하면 서버 본체 밖 일꾼(616-3 · 다른 겹 요청을 안 막음).""" if not 0 < min_m2 < max_m2: raise HTTPException(status_code=400, detail="0 < min_m2 < max_m2 여야 합니다.") root, epsg, _ = await _layer_root(project_id) + args = (root, terrain.models_dir_of(root), epsg, min_m2, max_m2) + pool = zero_order.heavy_pool() try: - value = await asyncio.to_thread( - zero_order.zero_order_layer, root, terrain.models_dir_of(root), epsg, min_m2, max_m2 - ) + value = await asyncio.to_thread(zero_order.zero_order_layer, *args, False) + if value is None: + loop = asyncio.get_running_loop() + value = await loop.run_in_executor(pool, zero_order.zero_order_layer, *args) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error - return {"status": "success", **value} + body = json.dumps({"status": "success", **value}, ensure_ascii=False) # 큰 선 묶음 = 바로 글로 + return Response(body, media_type="application/json") async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]: diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts index fb0cb92f1..9c7b0314c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts @@ -35,17 +35,19 @@ export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY)) export const writeSlopeBands = (bands: SlopeBand[]): void => writeByKey(PREF_KEY, JSON.stringify(bands)); -/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */ +/** 값 그림 → 칸 값(빨강 자리 = 회색 값) · 받은 blob 주소는 다 읽었으니 놓음(616-4). */ export function slopeValues(image: HTMLImageElement): ImageData { const canvas = document.createElement("canvas"); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; const context = canvas.getContext("2d", { willReadFrequently: true })!; context.drawImage(image, 0, 0); + if (image.src.startsWith("blob:")) URL.revokeObjectURL(image.src); return context.getImageData(0, 0, canvas.width, canvas.height); } -/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */ +/** 범위대로 칠한 그림(주소 = data URL · toBlob 의 한가 작업 기다림 최대 1 s 없앰 616-4 · + * 부르는 쪽 `URL.revokeObjectURL` 은 그대로 둬도 됨). */ export async function paintSlope( values: ImageData, bands: SlopeBand[], @@ -65,13 +67,7 @@ export async function paintSlope( canvas.width = values.width; canvas.height = values.height; canvas.getContext("2d")!.putImageData(out, 0, 0); - const blob = await new Promise((done, fail) => - canvas.toBlob( - (got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))), - "image/png", - ), - ); - const url = URL.createObjectURL(blob); + const url = canvas.toDataURL("image/png"); return { url, image: await loadImage(url) }; } From e1537dde96df64ccd6a35beac810293816be0753 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:31:44 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(D03=20=EC=A7=80=EB=8F=84=20=EA=B2=B9):?= =?UTF-8?q?=20=EC=83=9D=ED=83=9C=EC=9E=90=EC=97=B0=EB=8F=84=20=C2=B7=20?= =?UTF-8?q?=EC=82=B0=EC=82=AC=ED=83=9C=EC=9C=84=ED=97=98=20=EB=B2=94?= =?UTF-8?q?=EB=A1=80=20=EC=83=89=20=3D=20=EC=A1=B0=EA=B0=81=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=EC=83=89=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=E2=80=94?= =?UTF-8?q?=20=EC=83=9D=ED=83=9C=EC=9E=90=EC=97=B0=EB=8F=84=201=20=C2=B7?= =?UTF-8?q?=202=EB=93=B1=EA=B8=89=20=C2=B7=20=EB=B3=84=EB=8F=84=EA=B4=80?= =?UTF-8?q?=EB=A6=AC(3=EB=93=B1=EA=B8=89=20=EC=B9=A0=20=EC=97=86=EC=9D=8C)?= =?UTF-8?q?=20=C2=B7=20=EC=82=B0=EC=82=AC=ED=83=9C=20=EB=B2=94=EB=A1=80=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=ED=86=A0=ED=81=B0=20landslide-grade-1~5?= =?UTF-8?q?=20(PLAN=20622-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 산사태 옛 토큰 landslide-1~5 = 급경사 범위 색이 함께 써서 그대로 둠 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts | 5 ++--- D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts | 4 ++-- ui_template/ui_template_theme.css | 12 ++++++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts index 471dade28..c7aa20c51 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts @@ -77,12 +77,11 @@ const ECO_KINDS = [ { own: "국유", color: "--draw-map-eco-national" }, { own: "사유", color: "--draw-map-eco-private" }, ]; -/** 생태자연도 범례 = 환경부 원도 색(조각 그림 그대로). */ +/** 생태자연도 범례 = 환경부 WMS 그림 색(3등급 = 칠 없음). */ const ECOLOGY_LEGEND = [ { label: "1등급", color: "--draw-map-ecology-1" }, { label: "2등급", color: "--draw-map-ecology-2" }, - { label: "3등급", color: "--draw-map-ecology-3" }, - { label: "별도관리", color: "--draw-map-ecology-x" }, + { label: "별도관리지역", color: "--draw-map-ecology-x" }, ]; const HALO = "rgba(0, 0, 0, 0.55)"; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index aad87619f..ccf4dce8b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -164,10 +164,10 @@ export const ZONE_COLORS: Record = { wildlife: "--draw-map-zone-wildlife", greenbelt: "--draw-map-zone-greenbelt", }; -/** 산사태위험지도 범례(1 등급 = 가장 위험). */ +/** 산사태위험지도 범례(1 등급 = 가장 위험 · 색 = WMS 그림 색). */ export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({ label: `${grade}등급`, - color: `var(--draw-map-landslide-${grade})`, + color: `var(--draw-map-landslide-grade-${grade})`, })); /** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */ export type LineLayerKey = "faults" | "lineaments" | "zeroOrder"; diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index d11bcadc9..3294e052e 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -580,6 +580,11 @@ html.is-bm-route .m01lab-src__body * { --draw-map-landslide-3: #facc15; --draw-map-landslide-4: #a3e635; --draw-map-landslide-5: #4ade80; + --draw-map-landslide-grade-1: #dc1010; /* 산사태위험지도 범례 1~5등급 = 생활안전지도 WMS 그림 색 그대로 */ + --draw-map-landslide-grade-2: #ff0000; + --draw-map-landslide-grade-3: #ffc800; + --draw-map-landslide-grade-4: #b6ff8f; + --draw-map-landslide-grade-5: #33c3ff; --draw-map-fault: #f43f5e; /* 단층 */ --draw-map-lineament: #c4b5fd; /* 지질구조선(점선) */ --draw-map-zero-order: #7dd3fc; /* 0차곡(지형 추정 골짜기 머리 · 점선) */ @@ -590,10 +595,9 @@ html.is-bm-route .m01lab-src__body * { --draw-map-national-road-fire: #ff3b3b; /* 국유림 임도 산불 */ --draw-map-eco-national: #34d399; /* 경제림육성단지 국유(테두리) */ --draw-map-eco-private: #a78bfa; /* 경제림육성단지 사유(테두리) */ - --draw-map-ecology-1: #2e9e4f; /* 생태자연도 1등급(환경부 원도 색 — 범례만) */ - --draw-map-ecology-2: #b5d96b; /* 2등급 */ - --draw-map-ecology-3: #f2f2c2; /* 3등급 */ - --draw-map-ecology-x: #9b7bd4; /* 별도관리지역 */ + --draw-map-ecology-1: #1aa800; /* 생태자연도 1등급(환경부 WMS 그림 색 그대로 — 범례만 · 3등급 = 칠 없음) */ + --draw-map-ecology-2: #92d050; /* 2등급 */ + --draw-map-ecology-x: #ff8000; /* 별도관리지역 */ --draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */ --draw-map-measure: #ffffff; /* 거리재기 */ --draw-map-warn: #ff5a4d; /* 긋기 막힘(경고) */ From bd8acf82136f98d40b8e2e90decfcdbc89fbab32 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:31:46 +0900 Subject: [PATCH 4/7] auto: 2026-10-31 19:31 (ESD_LAPTOP) --- resources/data_forest | 1 + 1 file changed, 1 insertion(+) create mode 120000 resources/data_forest diff --git a/resources/data_forest b/resources/data_forest new file mode 120000 index 000000000..7bede4d5a --- /dev/null +++ b/resources/data_forest @@ -0,0 +1 @@ +C:/Program_coding/임도설계 및 견적자동화 프로그램 개발/resources/data_forest \ No newline at end of file From 31c8279d2c0b0c95b99271222a9ac2b962da3382 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:32:54 +0900 Subject: [PATCH 5/7] =?UTF-8?q?chore(=EC=84=A4=EC=A0=95):=20=EC=8B=A4?= =?UTF-8?q?=EC=88=98=EB=A1=9C=20=EC=98=AC=EB=9D=BC=EA=B0=84=20=EC=82=B0?= =?UTF-8?q?=EB=A6=BC=20=EC=9B=90=EC=9E=90=EB=A3=8C=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?resources/data=5Fforest=20=EB=BA=8C=20=E2=80=94=20git=20?= =?UTF-8?q?=EB=B0=96=20=EC=97=B0=EA=B2=B0(.gitignore=20=EB=8C=80=EC=83=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- resources/data_forest | 1 - 1 file changed, 1 deletion(-) delete mode 120000 resources/data_forest diff --git a/resources/data_forest b/resources/data_forest deleted file mode 120000 index 7bede4d5a..000000000 --- a/resources/data_forest +++ /dev/null @@ -1 +0,0 @@ -C:/Program_coding/임도설계 및 견적자동화 프로그램 개발/resources/data_forest \ No newline at end of file From 3bd3bcc5a80569b68d87003f90a813464f6a90ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:34:44 +0900 Subject: [PATCH 6/7] =?UTF-8?q?perf(B09=20[=EC=A0=80=EC=9E=A5]=20=C2=B7=20?= =?UTF-8?q?708-1):=20=EA=B0=99=EC=9D=80=20=EC=9E=85=EB=A0=A5=20=EB=90=98?= =?UTF-8?q?=ED=92=80=EC=9D=B4=20=EC=85=88=20=EB=AC=B6=EC=9D=8C=20=E2=80=94?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20[=EC=A0=80=EC=9E=A5]=203.7=20s=20?= =?UTF-8?q?=E2=86=92=201.7=20s=20=C2=B7=20=EA=B2=B0=EA=B3=BC=20=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EA=B0=99=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M01 캐시 `steady()` 범위 — 요청 한 번 안 파일 자국 한 번(77 번 × 19 ms → 1 번) · stale · forget 뒤 다시 · 범위 밖 그대로 - 두 번 셈(183-1) 2차 산근 = 1차가 본 단가(있음 · 값)가 같을 때만 1차 풀이 사본 · 다르면 다시 셈 - [저장] 단가 수요 한 번(셈 답에 넘김) · 단계 표시 = 셈과 함께 - B08 `once()` 범위 — B09 원천 읽기 때 토공 채집석 공제가 자재 집계표 답을 다시 씀(두 번 셈 → 한 번) - 시험: test_b09_save_speed 새로 · test_183 바꿔 끼운 셈 함수 = 새 memo 인자 받음 Co-Authored-By: Claude Opus 5.5 --- B08_Quantity/B08_Quantity_Router_Material.py | 29 +++++- B09_Estimation/B09_Estimation_Router.py | 73 +++++++++++---- B09_Estimation/B09_Estimation_Sangeun.py | 64 +++++++++++-- M01_MasterData/M01_MasterData_Store_Cache.py | 30 +++++- .../tester/test_183_b09_material_list.py | 2 +- resources/tester/test_b09_save_speed.py | 91 +++++++++++++++++++ 6 files changed, 256 insertions(+), 33 deletions(-) create mode 100644 resources/tester/test_b09_save_speed.py diff --git a/B08_Quantity/B08_Quantity_Router_Material.py b/B08_Quantity/B08_Quantity_Router_Material.py index aba4600f6..bed6b5f55 100644 --- a/B08_Quantity/B08_Quantity_Router_Material.py +++ b/B08_Quantity/B08_Quantity_Router_Material.py @@ -11,11 +11,14 @@ 단위수량 기본(143-4) = 연결된 사본 셈(`M02_Item_Copy.calc` = 층 셈 한 벌)의 일위대가 자재 · 로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산) · 참조 줄(승격 · 사전조합) 안 재료도 셈 · 기계 주연료 · 할증(ℓ)은 뺌(원가 몫 · 160-3). +`once()` 범위 = 저장본 세 표 한 번(B09 원천 읽기 — 토공집계표 채집석 공제가 같은 답을 씀). """ from __future__ import annotations import asyncio +import contextlib +import contextvars import re from decimal import Decimal from pathlib import Path @@ -231,10 +234,27 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any]) } +#: `once()` 범위 — {프로젝트 id: 저장본 세 표}(`material_sheets` 가 남김 · `picked_deduction` 이 씀) +_SAVED: contextvars.ContextVar[dict | None] = contextvars.ContextVar("b08_saved", default=None) + + +@contextlib.contextmanager +def once(): + """저장본이 안 바뀌는 범위 — 저장본 세 표를 한 번만 셈.""" + token = _SAVED.set({}) + try: + yield + finally: + _SAVED.reset(token) + + async def picked_deduction(project_id: UUID, root: str | Path) -> float | None: """채집석 공제 ㎥(160-5) — 저장본 기준 총괄 「채집석 공제」 줄 값 · 토공 잔토처리에서 뺌.""" - overrides = clean_overrides(quantity_settings(root).get(SETTINGS_KEY)) - rows = (await _sheets(project_id, root, overrides))["sheets"]["material"]["rows"] + got = (_SAVED.get() or {}).get(project_id) + if got is None: + overrides = clean_overrides(quantity_settings(root).get(SETTINGS_KEY)) + got = await _sheets(project_id, root, overrides) + rows = got["sheets"]["material"]["rows"] return next((row["amount"] for row in rows if row["key"] == DEDUCT_KEY), None) @@ -242,7 +262,10 @@ async def picked_deduction(project_id: UUID, root: str | Path) -> float | None: async def material_sheets(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) overrides = clean_overrides(quantity_settings(root).get(SETTINGS_KEY)) - return await _sheets(project_id, root, overrides) + got = await _sheets(project_id, root, overrides) + if (saved := _SAVED.get()) is not None: + saved[project_id] = got + return got @router.put("/{project_id}/quantity/materials") diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index b414a1727..7d801ed3b 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -69,6 +69,7 @@ from B08_Quantity.B08_Quantity_Milestone_Calc import compute_milestone from B08_Quantity.B08_Quantity_Router_Earthwork import earthwork_table from B08_Quantity.B08_Quantity_Router_Hydro import _project_root from B08_Quantity.B08_Quantity_Router_Material import material_sheets +from B08_Quantity.B08_Quantity_Router_Material import once as material_once from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone from B08_Quantity.B08_Quantity_Router_Milestone import site_info from B09_Estimation import B09_Estimation_Bill_Library as library @@ -95,6 +96,7 @@ from common_util.common_util_auth import verify_session from common_util.common_util_project_settings import reset_section from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_workflow_state import mark_stage +from M01_MasterData import M01_MasterData_Store_Cache as m01_cache from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete.M02_Item_Snapshot import importer from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _call, _root @@ -128,17 +130,19 @@ async def _b08_for(project_id: UUID, notes: list[str], reuse: bool) -> dict[str, async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]: - """B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음).""" + """B08 수량 원천 — 못 읽으면 빈칸 + 알림(B09 는 B08 를 고치지 않음) · + 자재 집계표 한 번(토공 채집석 공제가 같은 답 · `material_once`).""" out: dict[str, Any] = {} - for key, name, read in ( - ("materials", "자재 집계표", material_sheets), - ("earthwork", "토공집계표", earthwork_table), - ): - try: - out[key] = await read(project_id) - except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림 - notes.append(f"B08 {name} 못 읽음 — {getattr(error, 'detail', error)}") - out[key] = None + with material_once(): + for key, name, read in ( + ("materials", "자재 집계표", material_sheets), + ("earthwork", "토공집계표", earthwork_table), + ): + try: + out[key] = await read(project_id) + except Exception as error: # noqa: BLE001 — 원천 하나가 막혀도 나머지 표는 그림 + notes.append(f"B08 {name} 못 읽음 — {getattr(error, 'detail', error)}") + out[key] = None # 구조물 집계표 = 자재 집계표가 이미 부른 값(열 · 계 · 조합) — 두 번 안 부름 out["summary"] = (out["materials"] or {}).get("source") # 총괄 · 콘크리트 표 = B08 답의 `sheets` 한 층 아래(재료비목록표 · 운반 수량이 읽음 · 167-2) @@ -146,12 +150,15 @@ async def _b08(project_id: UUID, notes: list[str]) -> dict[str, Any]: return out -def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]: +def compute_sheets( + root: Path, inputs: dict[str, Any], user: dict[str, Any], memo: dict[str, Any] | None = None +) -> dict[str, Any]: """짝 셈 한 벌 — 바탕(144) → 산근(145-1 · 서버 단독) → 대가(145-2) → 내역(145-3) → 총괄(145-4). - 화면 짝 = `B09_Estimation_UI_Shell.computeSheets`(산근만 서버 답 그대로).""" + 화면 짝 = `B09_Estimation_UI_Shell.computeSheets`(산근만 서버 답 그대로) · + memo = 두 번 셈의 앞 산근 풀이(`Sangeun.collect`).""" base = compute_base(inputs, user) - sg = sangeun.build(root, base, user) # 145-1 — 굳힌 단가로 풀이(+ 166-2 손 줄 로직) + sg = sangeun.build(root, base, user, memo) # 145-1 — 굳힌 단가로 풀이(+ 166-2 손 줄 로직) by_key = {row["key"]: row for row in sg["sg_list"]} labor = compute_labor( inputs.get("daega"), {**base, "gaps": []}, by_key, user.get("daega_cover") @@ -171,13 +178,15 @@ def compute_sheets(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> tuple[dict, dict]: """183-1 두 번 셈 — 1차 셈으로 쓰는 자재만 남기고(`used_materials`) 재료비목록표 수량 = 1차 역전개 총수량(`material_qty` = 재료비수량금액집계표 수량) → 2차 셈 · 집계표. - `inputs` 를 고쳐 둠(화면 짝이 거른 입력 · 수량을 그대로 받음).""" - first = compute_sheets(root, inputs, user) + `inputs` 를 고쳐 둠(화면 짝이 거른 입력 · 수량을 그대로 받음) · + 2차 산근 = 1차가 본 단가가 같으면 1차 풀이 그대로(`memo`).""" + memo: dict[str, Any] = {} + first = compute_sheets(root, inputs, user, memo) summary = compute_summary(first, hopyo_lines(first)) keep = used_materials(first, inputs, user) inputs["materials"] = [m for m in inputs.get("materials") or [] if m["key"] in keep] inputs["material_qty"] = {row["key"]: row["qty"] for row in summary["sum_material"]} - sheets = compute_sheets(root, inputs, user) + sheets = compute_sheets(root, inputs, user, memo) return sheets, compute_summary(sheets, hopyo_lines(sheets)) @@ -218,11 +227,27 @@ async def _demand(root: Path, draft: dict[str, Any] | None = None) -> list[str]: async def payload( - project_id: UUID, root: Path, draft: dict[str, Any] | None = None, reuse_b08: bool = False + project_id: UUID, + root: Path, + draft: dict[str, Any] | None = None, + reuse_b08: bool = False, + keys: list[str] | None = None, +) -> dict[str, Any]: + """셈 답 한 벌 — keys = 방금 센 단가 수요(같은 저장 값 · [저장]) · 마스터 자국은 한 번만.""" + with m01_cache.steady(): + return await _payload(project_id, root, draft, reuse_b08, keys) + + +async def _payload( + project_id: UUID, + root: Path, + draft: dict[str, Any] | None, + reuse_b08: bool, + keys: list[str] | None, ) -> dict[str, Any]: notes: list[str] = [] b08 = await _b08_for(project_id, notes, reuse_b08) - keys = await _demand(root, draft) + keys = await _demand(root, draft) if keys is None else keys if not keys: notes.append( "연결된 구조물 조합 사본 없음 — 노무 · 경비 · 중기 단가 줄은 사본이 연결되면 생김" @@ -268,6 +293,11 @@ async def estimation(project_id: UUID) -> dict[str, Any]: @router.put("/{project_id}/estimation") async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> dict[str, Any]: root = await _project_root(project_id) + with m01_cache.steady(): + return await _save(project_id, root, body) + + +async def _save(project_id: UUID, root: Path, body: dict[str, Any]) -> dict[str, Any]: user = await asyncio.to_thread(store.write_user, root, body.get("user")) calc = await asyncio.to_thread(calc_user, root) # 263-2 운반 값 사본 = 지금 거리 if (rows := calc["bill"].get("rows")) != ((user.get("bill") or {}).get("rows") or []): @@ -279,8 +309,11 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> book = await asyncio.to_thread(store.read_book, root) book = await asyncio.to_thread(prices.extend_book, book, keys) await asyncio.to_thread(store.write_book, root, book) - await mark_stage(str(project_id), 6, "save") # [저장](B1) - return await payload(project_id, root) + # [저장](B1) 단계 표시는 셈과 함께 · 수요 = 방금 센 것(저장 값 · 사본 그대로) + out, _ = await asyncio.gather( + payload(project_id, root, keys=keys), mark_stage(str(project_id), 6, "save") + ) + return out @router.post("/{project_id}/estimation/complete") diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index ac8be89fd..02549d389 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -30,6 +30,7 @@ from __future__ import annotations +import copy import hashlib import json import re @@ -148,13 +149,49 @@ def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]: # ── 풀이 ────────────────────────────────────────────────────────────── +class _Seen(dict): + """읽은 키를 적는 단가 표 — 두 번 셈(183-1)에서 첫 풀이가 본 값만 견줌.""" + + def __init__(self, *args: Any) -> None: + super().__init__(*args) + self.seen: set[Any] = set() + + def __contains__(self, key: object) -> bool: + self.seen.add(key) + return super().__contains__(key) + + def __getitem__(self, key: Any) -> Any: + self.seen.add(key) + return super().__getitem__(key) + + def get(self, key: Any, default: Any = None) -> Any: + self.seen.add(key) + return super().get(key, default) + + def same(self, other: dict) -> bool: + """본 키마다 있음 · 값이 `other` 와 같음.""" + return all( + dict.__contains__(self, k) == dict.__contains__(other, k) + and dict.get(self, k) == dict.get(other, k) + for k in self.seen + ) + + class _Prices: """B09 표 단가 — 노무 · 중기(노 · 재 · 경) · 자재 적용 단가(키 → 값).""" def __init__(self, sheets: dict[str, Any]) -> None: - self.labor = {r["key"]: num(r.get("price")) for r in sheets.get("labor") or []} - self.machine = {r["key"]: r for r in sheets.get("machine") or []} - self.material = {r["key"]: num(r.get("applied")) for r in sheets.get("compare") or []} + self.labor = _Seen({r["key"]: num(r.get("price")) for r in sheets.get("labor") or []}) + self.machine = _Seen({r["key"]: r for r in sheets.get("machine") or []}) + self.material = _Seen( + {r["key"]: num(r.get("applied")) for r in sheets.get("compare") or []} + ) + + def same(self, other: _Prices) -> bool: + """첫 풀이가 본 단가가 `other` 에서도 같음 — 같으면 풀이도 같음.""" + return all( + getattr(self, k).same(getattr(other, k)) for k in ("labor", "machine", "material") + ) def _unit(code: str) -> tuple[str, Decimal]: @@ -538,10 +575,14 @@ def collect( entries: list[tuple[str, Path]], sheets: dict[str, Any], hands: list[tuple[str, str, dict[str, Any]]] | None = None, + memo: dict[str, Any] | None = None, ) -> dict[str, Any]: """`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」 · - `hands` = 손 줄 로직(166-2 · 사본 뒤 번호 · 마스터 로직).""" + `hands` = 손 줄 로직(166-2 · 사본 뒤 번호 · 마스터 로직) · + memo = 같은 사본 · 손 줄의 앞 풀이(두 번 셈) — 앞 풀이가 본 단가가 같으면 그 풀이 사본.""" solver = Solver(sheets) + if memo and memo["prices"].same(solver.prices): + return copy.deepcopy(memo["sg"]) for owner, folder in entries: whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) for src in rows_of(folder, whole): @@ -558,20 +599,27 @@ def collect( for b in blocks if b["sum"] is None ] - return { + out = { "sg": blocks, "sg_list": listing, "sg_gaps": gaps, "sg_notes": [NO_SOURCE] + ([] if blocks else [EMPTY]), "sg_hand": hand_keys, } + if memo is not None: + memo.update(prices=solver.prices, sg=copy.deepcopy(out)) + return out def build( - project_root: str | Path, sheets: dict[str, Any], user: dict[str, Any] | None = None + project_root: str | Path, + sheets: dict[str, Any], + user: dict[str, Any] | None = None, + memo: dict[str, Any] | None = None, ) -> dict[str, Any]: """연결된 사본 전부(구조물 id 차례 → 설계내역서 서고 사본 · 702-3)의 산근 + - 설계내역서 손 줄 로직(166-2 · 뒤 번호) — 차례 = `Labor_Source.daega_inputs`.""" + 설계내역서 손 줄 로직(166-2 · 뒤 번호) — 차례 = `Labor_Source.daega_inputs` · + memo = 같은 root · user 두 번 셈의 앞 풀이(`collect`).""" from B09_Estimation.B09_Estimation_Bill_Library import copies base = structures_dir(project_root) @@ -583,4 +631,4 @@ def build( entries += [ (f"서고 {(m.get('원본') or {}).get('name') or f.name}", f) for f, m in copies(project_root) ] - return collect(entries, sheets, hand_sources(user)) + return collect(entries, sheets, hand_sources(user), memo) diff --git a/M01_MasterData/M01_MasterData_Store_Cache.py b/M01_MasterData/M01_MasterData_Store_Cache.py index f57ec38f8..a5daa57d0 100644 --- a/M01_MasterData/M01_MasterData_Store_Cache.py +++ b/M01_MasterData/M01_MasterData_Store_Cache.py @@ -10,6 +10,8 @@ from __future__ import annotations +import contextlib +import contextvars import json import sys import threading @@ -28,6 +30,28 @@ _INDEX: dict = {} # 폴더 · 자국 · files · whole _BLOCK: dict[str, tuple[tuple, dict]] = {} # 로직 파일 → (자국, {키: `_check` 가 센 것}) _BASE: dict[str, tuple] = {} # 막힘을 셀 때 바탕이던, 로직 아닌 파일의 자국 _MARKED: dict[str, set] = {} # 우리가 고친 요소·표 파일 → 흔들린 자리(`narrow` 가 고른 것) +#: `steady()` 범위 — 폴더 → 자국(범위 안에선 자국 한 번 · `to_thread` 도 같은 묶음) +_STEADY: contextvars.ContextVar[dict | None] = contextvars.ContextVar("m01_steady", default=None) + + +@contextlib.contextmanager +def steady(): + """요청 한 번처럼 마스터가 안 바뀌는 범위 — `loaded` 가 파일 자국을 한 번만 셈.""" + token = _STEADY.set({}) if _STEADY.get() is None else None + try: + yield + finally: + if token is not None: + _STEADY.reset(token) + + +def _now(folder: Path) -> dict[str, tuple]: + held = _STEADY.get() + if held is None: + return _stamps(folder) + if folder not in held: + held[folder] = _stamps(folder) + return held[folder] def _stamps(folder: Path) -> dict[str, tuple]: @@ -84,6 +108,8 @@ def forget() -> None: _BLOCK.clear() _BASE.clear() _MARKED.clear() + if (held := _STEADY.get()) is not None: + held.clear() def stale(names, marks=()) -> None: @@ -97,6 +123,8 @@ def stale(names, marks=()) -> None: if was.get("그룹") != "로직": _MARKED.setdefault(name, set()).update(marks) _INDEX.pop("자국", None) + if (held := _STEADY.get()) is not None: + held.clear() def loaded(folder: Path) -> tuple[dict[str, dict], object]: @@ -105,7 +133,7 @@ def loaded(folder: Path) -> tuple[dict[str, dict], object]: if _INDEX.get("폴더") != folder: # 시험은 사본 폴더로 바꿈 forget() _INDEX["폴더"] = folder - now = _stamps(folder) + now = _now(folder) for gone in [name for name in _READ if name not in now]: del _READ[gone] for name, stamp in now.items(): diff --git a/resources/tester/test_183_b09_material_list.py b/resources/tester/test_183_b09_material_list.py index e6905631c..e93e06173 100644 --- a/resources/tester/test_183_b09_material_list.py +++ b/resources/tester/test_183_b09_material_list.py @@ -59,7 +59,7 @@ def test_수량_원천_material_qty_거울(): def test_두_번_셈_후보_줄_빠지고_수량_역전개(monkeypatch): calls = [] - def fake(root, inputs, user): + def fake(root, inputs, user, memo=None): calls.append([m["key"] for m in inputs["materials"]]) base = compute_base(inputs, user) bill = {"rows": [{"kind": "item", "qty": 3, "ref": {"tab": "sg", "key": "SG1"}}]} diff --git a/resources/tester/test_b09_save_speed.py b/resources/tester/test_b09_save_speed.py new file mode 100644 index 000000000..801112b7f --- /dev/null +++ b/resources/tester/test_b09_save_speed.py @@ -0,0 +1,91 @@ +"""B09 [저장] 속도 — 같은 결과 범위 안 다시 셈 줄임 지킴. + +- 산근 두 번 셈(183-1) = 1차가 본 단가(있음 · 값)가 같을 때만 1차 풀이 그대로(`_Prices.same`) +- M01 `steady()` 범위 = 파일 자국 한 번 · `stale` 뒤 다시 셈 · 범위 밖은 늘 셈 +- B08 `once()` 범위 = 채집석 공제가 자재 집계표 답을 다시 씀(셈 한 번) +""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Material as material # noqa: E402 +from B09_Estimation.B09_Estimation_Sangeun import _Prices # noqa: E402 +from M01_MasterData import M01_MasterData_Store_Cache as cache # noqa: E402 + +SHEETS = { + "labor": [{"key": "L1", "price": 100}, {"key": "L2", "price": 5}], + "compare": [{"key": "M1", "applied": 3}], + "machine": [{"key": "E1", "labor": 1, "material": 2, "expense": 3}], +} + + +def _seen() -> _Prices: + got = _Prices(SHEETS) + got.labor.get("L1") + assert "M9" not in got.material + got.machine.get("E1") + return got + + +def test_산근_같은_단가면_다시_씀() -> None: + other = {**SHEETS, "labor": [{"key": "L1", "price": 100}, {"key": "L2", "price": 999}]} + assert _seen().same(_Prices({**other, "compare": []})) # 안 본 L2 · M1 은 안 셈 + + +def test_산근_본_단가_바뀌면_다시_셈() -> None: + assert not _seen().same(_Prices({**SHEETS, "labor": [{"key": "L1", "price": 101}]})) + added = {**SHEETS, "compare": [{"key": "M9", "applied": 1}]} + assert not _seen().same(_Prices(added)) # 없던 자재가 생김 + machine = {**SHEETS, "machine": [{"key": "E1", "labor": 1, "material": 2, "expense": 4}]} + assert not _seen().same(_Prices(machine)) + + +def test_M01_범위_안_자국_한_번(monkeypatch) -> None: + calls = [] + monkeypatch.setattr(cache, "_stamps", lambda folder: calls.append(folder) or {}) + folder = Path("x") + cache._now(folder) + cache._now(folder) + assert len(calls) == 2 # 범위 밖 = 늘 셈 + with cache.steady(): + cache._now(folder) + cache._now(folder) + assert len(calls) == 3 + cache.stale([]) # 스스로 고친 자리 = 다시 셈 + cache._now(folder) + assert len(calls) == 4 + cache._now(folder) + assert len(calls) == 5 + + +def test_B08_범위_안_자재_집계표_한_번(monkeypatch, tmp_path) -> None: + calls = [] + rows = [{"key": material.DEDUCT_KEY, "amount": 12.5}] + + async def sheets(_pid, _root, _over): + calls.append(1) + return {"sheets": {"material": {"rows": rows}}} + + async def root(_pid): + return tmp_path + + monkeypatch.setattr(material, "_sheets", sheets) + monkeypatch.setattr(material, "_project_root", root) + monkeypatch.setattr(material, "quantity_settings", lambda _root: {}) + + async def run() -> list: + out = [await material.picked_deduction("p", tmp_path)] + with material.once(): + await material.material_sheets("p") + out.append(await material.picked_deduction("p", tmp_path)) + out.append(await material.picked_deduction("p", tmp_path)) + return out + + assert asyncio.run(run()) == [12.5, 12.5, 12.5] + assert len(calls) == 3 # 범위 안 공제 = 셈 없음 From 476f2391417c65dafb19bb569bb3f624b5a42b84 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 19:37:09 +0900 Subject: [PATCH 7/7] =?UTF-8?q?knowledge(=EC=9A=94=EA=B5=AC):=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=86=8D=EB=8F=84=20=EC=9E=AC=EA=B8=B0(sub6)=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/raw/요구_내역서/_점검_저장속도.md | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/raw/요구_내역서/_점검_저장속도.md diff --git a/docs/raw/요구_내역서/_점검_저장속도.md b/docs/raw/요구_내역서/_점검_저장속도.md new file mode 100644 index 000000000..597eec4c5 --- /dev/null +++ b/docs/raw/요구_내역서/_점검_저장속도.md @@ -0,0 +1,63 @@ +# B09 [저장] 속도 (sub6 · 2026-10-31) + +검증 프로젝트 B 검증_sub6 · 서버 8008 · 5181. + +## 1. 다시 세운 꼴 + +| 몫 | 어떻게 | 수 | +| --- | --- | --- | +| 내역 줄 | 거창 엑셀 내역 불러오기(넘겨받기 · 자동 줄 남김) + 요율 · 단가표 패치 | 118 줄 | +| 서고 사본 | 임시 개인 층(tmp)에 거창 호표 14 [모두 고정으로 들여오기] → 불러오기 이음 8 · 시스템 사전조합 SC-000001 1 | 사본 9 | +| 로직 연결 | 내역 줄 하나에 GF000099 | 1 | +| 구조물 조합 | 관 지점 351 m 본체 · 유입 · 유출에 시스템 조합 SS000054 · 55 · 56 | 3 | + +## 2. 재기 — 어디서 시간이 드나 + +먼저 고친 것: 재는 틀의 `localhost` = 요청마다 2 s 더 붙음(IPv6 먼저 시도 · `/api/health` 2.3 s) → 127.0.0.1 로. 지난 점검 ⑧ 숫자(206.8 · 257.3 · B08 135 s)는 지금 코드 · 같은 꼴로 안 나옴 — 같은 꼴 화면 [저장] 3.6~3.8 s. + +[저장] 한 번(PUT) 단계 — 고치기 전(in-process · 데운 캐시): + +| 단계 | 불림 | 시간 | 속 | +| --- | --- | --- | --- | +| 산근 풀이 `Sangeun.build` | 2 | 1.6 s | 두 번 셈(183-1)이 같은 풀이를 다시 · 식 글 풀기 5,522 번 · 글 짓기 | +| 일위대가 입력 `daega_inputs` | 1 | 1.4 s | M01 `loaded` 77 번 × 파일 자국 19 ms | +| B08 원천 `_b08` | 1 | 1.5 s | 토공집계표가 채집석 공제 한 줄 때문에 자재 집계표를 한 번 더 셈 | +| 단가 수요 `_demand` | 2 | 0.7 s | [저장] 안 같은 저장 값으로 두 번 | +| 단계 표시 `mark_stage` | 1 | 0.1~0.9 s | DB · 셈과 차례로 | +| 그 밖(사본 정리 · 단가 책 · 파일 쓰기) | | < 0.1 s | | + +## 3. 고친 것 + +| # | 고침 | 파일 | +| --- | --- | --- | +| ① | M01 `steady()` 범위 — 요청 한 번 안 파일 자국 한 번(`stale` · `forget` 뒤 다시) · 범위 밖은 그대로 | `M01_MasterData_Store_Cache.py` | +| ② | 두 번 셈 2차 산근 = 1차가 본 단가(있음 · 값)가 같으면 1차 풀이 사본 · 다르면 다시 셈 | `B09_Estimation_Sangeun.py` · `B09_Estimation_Router.py` | +| ③ | [저장] 단가 수요 한 번(셈 답에 넘김) · 단계 표시는 셈과 함께 · 셈 답 = M01 범위 안 | `B09_Estimation_Router.py` | +| ④ | B08 `once()` 범위 — B09 원천 읽기 때 채집석 공제가 자재 집계표 답을 다시 씀 | `B08_Quantity_Router_Material.py` · `B09_Estimation_Router.py` | + +서버 셈은 정본 그대로 — [저장] = 서버가 저장 값으로 다시 셈 · 브라우저 값 안 받아 적음. + +## 4. 전후 + +| 잼 | 전 | 후 | +| --- | --- | --- | +| B09 [저장] 서버(PUT · 세 번) | 3.8 · 3.7 · 2.6 s | 1.6 · 2.1 · 1.4 s | +| B09 화면 [저장](누름 → 오버레이 걷힘 · 세 번) | 3.8 · 3.6 · 3.7 s | 1.5 · 1.7 · 1.8 s | +| B09 단계 몫(in-process) | 산근 1.6 · 입력 1.4 · B08 1.5 · 수요 0.7 s | 산근 0.2 · 입력 0.13 · B08 0.6~0.7 · 수요 0.15 s | +| B08 화면 [저장](옵션 · 거리이정표 · 다시 그림 · 세 번) | 0.6 · 0.4 · 0.7 s | 길 안 바뀜(같음) | +| B08 자재 PUT(세 번) | 1.1 · 0.7 · 0.4 s | 길 안 바뀜 | + +결과 같음 — [저장] 응답(셈 표 · 집계표 · 사용자 값 · 입력 · 알림) · 저장 파일 26(사용자 값 · 단가 책(굳힌 때 칸 뺌) · 서고 사본) 바이트 같음 · 직접공사비 410,635,103. + +화면 [저장] 오버레이 = 2 s 안에 걷힘(멈춤 없음 · 답 뒤 화면 셈 0.1~0.2 s). + +## 5. 확인 + +- 고친 대상 시험 47 파일 + 새 `test_b09_save_speed.py` — 349 통과 · 실패 2: `test_183`(바꿔 끼운 셈 함수가 새 인자 못 받음 → 시험 고침 → 통과) · `test_166_2` 우클릭 메뉴 TS = `linkMenu` 없음(707-4 sub5 합류 몫 · 이 일감 TS 안 고침 · 그대로) +- `npm run typecheck` 통과 · 거울 짝(`Calc_*`) 안 고침 +- 되돌림 — 검증_sub6 B09 · 구조물 사본 · 관 지점 = 시작 판(서버 줄 37 · 빈 줄 23 · 연결 0) · 임시 개인 층 = tmp 만(진짜 층 쓰기 0) + +## 6. 남은 것 + +- 지난 점검 206 · 257 s · B08 135 s = 지금 코드로 안 나옴 — 그때 같은 PC 다른 창 짐(서버 13 · 시험)이나 구조물 조합(개인 층 · 규격 열)이 원인일 수 있음 · 다시 나면 그 순간 2장 꼴로 단계 시각(서버 안 cProfile) +- 남은 큰 몫 = B08 원천 0.6~0.7 s(구조물 집계표) · 식 글 풀기(`resources/master_data/scripts` = 안 고침)