Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_1

This commit is contained in:
2026-10-27 20:12:57 +09:00
23 changed files with 1814 additions and 176 deletions
+253
View File
@@ -0,0 +1,253 @@
"""D 예정노선 수동 직선 그리기 셈 — [저장] 정본 쪽 (PLAN 562-1 · 요구 389 12 · 13).
구간 길이 · 원지반 기울기 · 길이 고정 · 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점.
높이는 인자 `height_at(x, y)`(nan = 지형 밖).
⚠ 파이썬·TS 짝 — `logic/D03_PlanAnalysis_Draw.ts` 같은 이름(camel). 한쪽을 고치면 다른 쪽도 고치고
거울 시험 `resources/tester/test_562_1_plan_draw.py`.
"""
from __future__ import annotations
import math
from collections.abc import Callable, Sequence
Vertex = tuple[float, float]
HeightAt = Callable[[float, float], float]
# 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수 — 짝 TS 상수
SLOPE_STEP_M = 1
BISECT = 40
COMPASS_SAMPLES = 360
EPS = 1e-9
def _finite(value: float) -> bool:
return math.isfinite(value)
def segment_length(a: Sequence[float], b: Sequence[float]) -> float:
"""수평 길이(m)."""
dx = b[0] - a[0]
dy = b[1] - a[1]
return math.sqrt(dx * dx + dy * dy)
def ground_slope_pct(a: Sequence[float], b: Sequence[float], height_at: HeightAt) -> float | None:
"""원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · None = 지형 밖 · 길이 0."""
length = segment_length(a, b)
if length < EPS:
return None
za = height_at(a[0], a[1])
zb = height_at(b[0], b[1])
if not _finite(za) or not _finite(zb):
return None
return ((zb - za) / length) * 100
def fixed_length_point(
start: Sequence[float], cursor: Sequence[float], length_m: float
) -> Vertex | None:
"""길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 None."""
d = segment_length(start, cursor)
if d < EPS:
return None
return (
start[0] + ((cursor[0] - start[0]) / d) * length_m,
start[1] + ((cursor[1] - start[1]) / d) * length_m,
)
def fixed_slope_point(
start: Sequence[float],
cursor: Sequence[float],
slope_pct: float,
height_at: HeightAt,
max_m: float = 1000,
) -> Vertex | None:
"""기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) ·
`max_m` 안에 없거나 지형 밖에 닿으면 None."""
d = segment_length(start, cursor)
z0 = height_at(start[0], start[1])
if d < EPS or not _finite(z0):
return None
ux = (cursor[0] - start[0]) / d
uy = (cursor[1] - start[1]) / d
grade = abs(slope_pct) / 100
def at(t: float) -> Vertex:
return (start[0] + ux * t, start[1] + uy * t)
def f(t: float) -> float:
p = at(t)
return abs(height_at(p[0], p[1]) - z0) - grade * t
lo = 0.0
flo = math.nan
i = 1
while i * SLOPE_STEP_M <= max_m:
t = i * SLOPE_STEP_M
ft = f(t)
if not _finite(ft):
return None
if ft == 0:
return at(t)
if i > 1 and (flo < 0) != (ft < 0):
hi = t
for _ in range(BISECT):
mid = (lo + hi) / 2
fm = f(mid)
if not _finite(fm):
return None
if (fm < 0) == (flo < 0):
lo = mid
flo = fm
else:
hi = mid
return at((lo + hi) / 2)
lo = t
flo = ft
i += 1
return None
def compass_candidates(
start: Sequence[float], length_m: float, slope_pct: float, height_at: HeightAt
) -> list[Vertex]:
"""둘 다(양각기) — 반지름 `length_m` 원 위에서 |기울기| = 목표인 후보들
(방위 표본 · 이분 · 동쪽부터 반시계)."""
z0 = height_at(start[0], start[1])
if not length_m > EPS or not _finite(z0):
return []
rise = (abs(slope_pct) / 100) * length_m
step = (2 * math.pi) / COMPASS_SAMPLES
def at(angle: float) -> Vertex:
return (start[0] + math.cos(angle) * length_m, start[1] + math.sin(angle) * length_m)
def h(angle: float) -> float:
p = at(angle)
return abs(height_at(p[0], p[1]) - z0) - rise
values = [h(k * step) for k in range(COMPASS_SAMPLES)]
out: list[Vertex] = []
for k in range(COMPASS_SAMPLES):
hk = values[k]
hn = values[(k + 1) % COMPASS_SAMPLES]
if not _finite(hk) or not _finite(hn):
continue
if hk == 0:
out.append(at(k * step))
continue
if hn == 0 or (hk < 0) == (hn < 0):
continue
lo = k * step
hi = lo + step
flo = hk
ok = True
for _ in range(BISECT):
mid = (lo + hi) / 2
fm = h(mid)
if not _finite(fm):
ok = False
break
if (fm < 0) == (flo < 0):
lo = mid
flo = fm
else:
hi = mid
if ok:
out.append(at((lo + hi) / 2))
return out
def compass_point(
start: Sequence[float],
cursor: Sequence[float],
length_m: float,
slope_pct: float,
height_at: HeightAt,
) -> Vertex | None:
"""둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 None."""
best: Vertex | None = None
best_d = math.inf
for p in compass_candidates(start, length_m, slope_pct, height_at):
d = segment_length(p, cursor)
if d < best_d:
best = p
best_d = d
return best
def line_length(line: Sequence[Sequence[float]]) -> float:
"""선 길이(m) — 앞에서부터 더함."""
total = 0.0
for i in range(1, len(line)):
total += segment_length(line[i - 1], line[i])
return total
def point_at_distance(line: Sequence[Sequence[float]], m: float) -> Vertex:
"""선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상."""
if m <= 0:
return (line[0][0], line[0][1])
acc = 0.0
for i in range(1, len(line)):
a = line[i - 1]
b = line[i]
seg = segment_length(a, b)
if seg > 0 and acc + seg >= m:
t = (m - acc) / seg
return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
acc += seg
return (line[-1][0], line[-1][1])
def distance_along(line: Sequence[Sequence[float]], point: Sequence[float]) -> float:
"""점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간)."""
best = 0.0
best_d = math.inf
acc = 0.0
for i in range(1, len(line)):
a = line[i - 1]
b = line[i]
seg = segment_length(a, b)
t = 0.0
if seg > 0:
t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / (
seg * seg
)
t = min(1.0, max(0.0, t))
p = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t)
d = segment_length(p, point)
if d < best_d:
best_d = d
best = acc + seg * t
acc += seg
return best
def slice_line(line: Sequence[Sequence[float]], from_m: float, to_m: float) -> list[Vertex]:
"""선 조각 — `from_m`~`to_m` 사이(끝점 + 그 사이 꺾임점)."""
lo = min(from_m, to_m)
hi = max(from_m, to_m)
out = [point_at_distance(line, lo)]
acc = 0.0
for i in range(1, len(line) - 1):
acc += segment_length(line[i - 1], line[i])
if lo + EPS < acc < hi - EPS:
out.append((line[i][0], line[i][1]))
out.append(point_at_distance(line, hi))
return out
def station_chainages(length_m: float, interval_m: float = 20) -> list[float]:
"""측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터)."""
out: list[float] = [0]
k = 1
while k * interval_m < length_m - 1e-6:
out.append(k * interval_m)
k += 1
if length_m > 1e-6:
out.append(length_m)
return out
@@ -0,0 +1,236 @@
/* =============================================================================
* D03_PlanAnalysis_Draw.ts
* D 예정노선 수동 직선 그리기 셈(PLAN 562-1 · 요구 389 12 · 13) — 구간 길이 · 원지반 기울기 · 길이 고정 ·
* 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. 높이는 인자 `heightAt(x, y)`(NaN = 지형 밖).
* DOM · fetch · sessionStorage 없음.
*
* ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Draw.py` 같은 이름(snake). 한쪽을 고치면 다른 쪽도 고치고 거울 시험
* `resources/tester/test_562_1_plan_draw.py`. 화면은 그리는 즉시 이 짝으로 · [저장]은 서버가 다시 셈.
* ========================================================================== */
export type Vertex = [number, number];
export type HeightAt = (x: number, y: number) => number;
/** 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수. */
const SLOPE_STEP_M = 1;
const BISECT = 40;
const COMPASS_SAMPLES = 360;
const EPS = 1e-9;
/** 수평 길이(m). */
export function segmentLength(a: Vertex, b: Vertex): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
return Math.sqrt(dx * dx + dy * dy);
}
/** 원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · null = 지형 밖 · 길이 0. */
export function groundSlopePct(a: Vertex, b: Vertex, heightAt: HeightAt): number | null {
const length = segmentLength(a, b);
if (length < EPS) return null;
const za = heightAt(a[0], a[1]);
const zb = heightAt(b[0], b[1]);
if (!Number.isFinite(za) || !Number.isFinite(zb)) return null;
return ((zb - za) / length) * 100;
}
/** 길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 null. */
export function fixedLengthPoint(from: Vertex, cursor: Vertex, lengthM: number): Vertex | null {
const d = segmentLength(from, cursor);
if (d < EPS) return null;
return [
from[0] + ((cursor[0] - from[0]) / d) * lengthM,
from[1] + ((cursor[1] - from[1]) / d) * lengthM,
];
}
/** 기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) ·
* `maxM` 안에 없거나 지형 밖에 닿으면 null. */
export function fixedSlopePoint(
from: Vertex,
cursor: Vertex,
slopePct: number,
heightAt: HeightAt,
maxM: number = 1000,
): Vertex | null {
const d = segmentLength(from, cursor);
const z0 = heightAt(from[0], from[1]);
if (d < EPS || !Number.isFinite(z0)) return null;
const ux = (cursor[0] - from[0]) / d;
const uy = (cursor[1] - from[1]) / d;
const grade = Math.abs(slopePct) / 100;
const at = (t: number): Vertex => [from[0] + ux * t, from[1] + uy * t];
const f = (t: number): number => {
const p = at(t);
return Math.abs(heightAt(p[0], p[1]) - z0) - grade * t;
};
let lo = 0;
let flo = NaN;
for (let i = 1; i * SLOPE_STEP_M <= maxM; i += 1) {
const t = i * SLOPE_STEP_M;
const ft = f(t);
if (!Number.isFinite(ft)) return null;
if (ft === 0) return at(t);
if (i > 1 && flo < 0 !== ft < 0) {
let hi = t;
for (let k = 0; k < BISECT; k += 1) {
const mid = (lo + hi) / 2;
const fm = f(mid);
if (!Number.isFinite(fm)) return null;
if (fm < 0 === flo < 0) {
lo = mid;
flo = fm;
} else hi = mid;
}
return at((lo + hi) / 2);
}
lo = t;
flo = ft;
}
return null;
}
/** 둘 다(양각기) — 반지름 `lengthM` 원 위에서 |기울기| = 목표인 후보들(방위 표본 · 이분 · 동쪽부터
* 반시계). */
export function compassCandidates(
from: Vertex,
lengthM: number,
slopePct: number,
heightAt: HeightAt,
): Vertex[] {
const z0 = heightAt(from[0], from[1]);
if (!(lengthM > EPS) || !Number.isFinite(z0)) return [];
const rise = (Math.abs(slopePct) / 100) * lengthM;
const step = (2 * Math.PI) / COMPASS_SAMPLES;
const at = (angle: number): Vertex => [
from[0] + Math.cos(angle) * lengthM,
from[1] + Math.sin(angle) * lengthM,
];
const h = (angle: number): number => {
const p = at(angle);
return Math.abs(heightAt(p[0], p[1]) - z0) - rise;
};
const values: number[] = [];
for (let k = 0; k < COMPASS_SAMPLES; k += 1) values.push(h(k * step));
const out: Vertex[] = [];
for (let k = 0; k < COMPASS_SAMPLES; k += 1) {
const hk = values[k];
const hn = values[(k + 1) % COMPASS_SAMPLES];
if (!Number.isFinite(hk) || !Number.isFinite(hn)) continue;
if (hk === 0) {
out.push(at(k * step));
continue;
}
if (hn === 0 || hk < 0 === hn < 0) continue;
let lo = k * step;
let hi = lo + step;
let flo = hk;
let ok = true;
for (let i = 0; i < BISECT; i += 1) {
const mid = (lo + hi) / 2;
const fm = h(mid);
if (!Number.isFinite(fm)) {
ok = false;
break;
}
if (fm < 0 === flo < 0) {
lo = mid;
flo = fm;
} else hi = mid;
}
if (ok) out.push(at((lo + hi) / 2));
}
return out;
}
/** 둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 null. */
export function compassPoint(
from: Vertex,
cursor: Vertex,
lengthM: number,
slopePct: number,
heightAt: HeightAt,
): Vertex | null {
let best: Vertex | null = null;
let bestD = Infinity;
for (const p of compassCandidates(from, lengthM, slopePct, heightAt)) {
const d = segmentLength(p, cursor);
if (d < bestD) {
best = p;
bestD = d;
}
}
return best;
}
/** 선 길이(m) — 앞에서부터 더함. */
export function lineLength(line: ReadonlyArray<Vertex>): number {
let total = 0;
for (let i = 1; i < line.length; i += 1) total += segmentLength(line[i - 1], line[i]);
return total;
}
/** 선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상. */
export function pointAtDistance(line: ReadonlyArray<Vertex>, m: number): Vertex {
if (m <= 0) return [line[0][0], line[0][1]];
let acc = 0;
for (let i = 1; i < line.length; i += 1) {
const a = line[i - 1];
const b = line[i];
const seg = segmentLength(a, b);
if (seg > 0 && acc + seg >= m) {
const t = (m - acc) / seg;
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
acc += seg;
}
const last = line[line.length - 1];
return [last[0], last[1]];
}
/** 점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간). */
export function distanceAlong(line: ReadonlyArray<Vertex>, point: Vertex): number {
let best = 0;
let bestD = Infinity;
let acc = 0;
for (let i = 1; i < line.length; i += 1) {
const a = line[i - 1];
const b = line[i];
const seg = segmentLength(a, b);
let t = 0;
if (seg > 0) {
t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / (seg * seg);
t = Math.min(1, Math.max(0, t));
}
const p: Vertex = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
const d = segmentLength(p, point);
if (d < bestD) {
bestD = d;
best = acc + seg * t;
}
acc += seg;
}
return best;
}
/** 선 조각 — `fromM`~`toM` 사이(끝점 + 그 사이 꺾임점). */
export function sliceLine(line: ReadonlyArray<Vertex>, fromM: number, toM: number): Vertex[] {
const lo = Math.min(fromM, toM);
const hi = Math.max(fromM, toM);
const out: Vertex[] = [pointAtDistance(line, lo)];
let acc = 0;
for (let i = 1; i < line.length - 1; i += 1) {
acc += segmentLength(line[i - 1], line[i]);
if (acc > lo + EPS && acc < hi - EPS) out.push([line[i][0], line[i][1]]);
}
out.push(pointAtDistance(line, hi));
return out;
}
/** 측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터). */
export function stationChainages(lengthM: number, intervalM: number = 20): number[] {
const out = [0];
for (let k = 1; k * intervalM < lengthM - 1e-6; k += 1) out.push(k * intervalM);
if (lengthM > 1e-6) out.push(lengthM);
return out;
}
+288
View File
@@ -0,0 +1,288 @@
"""E02 [해석 시작] 체인 (요구 390 1 · PLAN 564-1).
차례 = 2D → 3D(모든 구간 · 건기 · 우기) → 암반 → 광역 → 보고서. 계산 자리 = ③ 서버 단독.
각 페이지 해석 함수를 그대로 부름(그 파일 안 고침) · 단계마다 입력 지문이 저장본과 같으면 건너뜀
· 결과는 각 페이지 저장 자리에 영구 저장 · 한 단계가 실패해도 다음 단계로.
도는 동안 그 페이지 진행 표(`_JOBS`)에 같은 일감을 꽂음 → E03 · E04 · E07 화면이 「해석 중」
· 그 페이지 [해석] 은 409. [끊기] = 다음 확인 때 멈춤(3D 는 엔진 안 · 그 밖은 단계 사이).
진행 꼴 = {running, percent, stage, stage_name, steps:[{key, name, state, seconds}],
message, error, cancelled} · state = wait · run · done · skip · error · cancel
"""
from __future__ import annotations
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
from uuid import UUID
logger = logging.getLogger(__name__)
#: 단계 — (열쇠, 이름, 비율 몫) · 몫 = 1.2 km 실측 시간 비(2D 1~3 분 · 3D 건기 · 우기 약 25 분)
STEPS: tuple[tuple[str, str, float], ...] = (
("2d", "2D 측점 해석", 10.0),
("3d", "3D 구간 해석", 84.0),
("rock", "암반 해석", 2.0),
("area", "광역 위험", 2.0),
("report", "보고서", 2.0),
)
CONDITIONS_3D = ("dry", "wet")
Report = Callable[[float, str], None] # (단계 안 비율 0~1, 글)
Stopped = Callable[[], bool]
Step = Callable[[Report, Stopped], Awaitable[str]] # → "done" | "skip"
_JOBS: dict[str, dict[str, Any]] = {}
_STOPS: dict[str, bool] = {}
_TASKS: set[asyncio.Task] = set()
def idle() -> dict[str, Any]:
return {
"running": False,
"percent": 0,
"stage": None,
"stage_name": None,
"steps": [{"key": k, "name": n, "state": "wait", "seconds": None} for k, n, _ in STEPS],
"message": None,
"error": None,
"cancelled": False,
}
def _mark_stage(project_id: str, key: str, state: str) -> None:
"""대시보드 단계 상태 — ponytail: 지금은 안 적음 · 565-1 함수가 들어오면 여기서 부름."""
async def run_chain(
job: dict[str, Any], steps: dict[str, Step], stopped: Stopped, project_id: str = ""
) -> None:
"""STEPS 차례로 돎 — job 을 제자리에서 고침(진행 GET 이 그대로 읽음)."""
total = sum(w for _, _, w in STEPS)
base = 0.0
errors: list[str] = []
for index, (key, name, weight) in enumerate(STEPS):
row = job["steps"][index]
if stopped():
job.update(cancelled=True, message="끊음")
row["state"] = "cancel"
break
job.update(stage=key, stage_name=name, message=name)
row["state"] = "run"
_mark_stage(project_id, key, "run")
def report(frac: float, message: str, base: float = base, weight: float = weight) -> None:
frac = min(1.0, max(0.0, float(frac)))
job.update(percent=min(99, int(100 * (base + weight * frac) / total)), message=message)
started = time.perf_counter()
try:
row["state"] = await steps[key](report, stopped)
except InterruptedError:
row["state"] = "cancel"
job.update(cancelled=True, message="끊음")
break
except Exception as error: # noqa: BLE001 — 단계 실패 격리 · 까닭은 그대로 올림
logger.exception("E02 체인 %s 실패: project_id=%s", key, project_id)
row["state"] = "error"
errors.append(f"{name}: {getattr(error, 'detail', None) or error}")
finally:
row["seconds"] = round(time.perf_counter() - started, 1)
_mark_stage(project_id, key, row["state"])
base += weight
job["percent"] = min(99, int(100 * base / total))
job["error"] = " · ".join(errors) or None
if not job["cancelled"]:
job.update(percent=100, message="끝")
job.update(stage=None, stage_name=None)
# ---------------------------------------------------------------------------
# 실제 단계 — 페이지 함수 부르기(늦은 import · E03 · E06 라우터가 E02 라우터를 import 함)
# ---------------------------------------------------------------------------
def _plug(jobs: dict[str, dict[str, Any]], pid: str, extra: dict[str, Any]) -> dict[str, Any]:
"""그 페이지 진행 표에 체인 일감을 꽂음 — 페이지 화면 「해석 중」 · 페이지 [해석] 409."""
job = {"running": True, "percent": 0, "message": "한 번에 해석", "error": None, **extra}
jobs[pid] = job
return job
def build_steps(project_id: UUID, project_root: Path) -> dict[str, Step]:
from E01_StabRegister import E01_StabRegister_Source as source
from E03_StabSection import E03_StabSection_Router as e03
from E04_Stab3D import E04_Stab3D_Router as e04
from E04_Stab3D import E04_Stab3D_Run as run3d
from E05_StabReport import E05_StabReport_Build as e05_build
from E05_StabReport import E05_StabReport_Router as e05
from E05_StabReport import E05_StabReport_Store as e05_store
from E06_StabRock import E06_StabRock_Router as e06
from E07_StabArea import E07_StabArea_Router as e07
pid = str(project_id)
root = project_root
async def step_2d(report: Report, _stopped: Stopped) -> str:
plan = await asyncio.to_thread(e03.plan_run, root, e03._ground(root))
if e03.read_derived(root, e03.KIND_SUMMARY, plan["fp"]):
return "skip"
page = _plug(e03._JOBS, pid, {"computed": 0})
def relay(percent: int, message: str) -> None:
page.update(percent=int(percent), message=message)
report(percent / 100, f"2D {message}")
try:
page["computed"] = await asyncio.to_thread(e03.run_all, root, plan, relay)
page.update(percent=100, message="끝")
finally:
page["running"] = False
return "done"
def search_settings() -> dict[str, Any]:
"""3D 탐색 설정 = 가장 최근 저장본 설정 · 없으면 기본값(구간 · 조건 빼고)."""
files = sorted(run3d.results_dir(root).glob("*.json"), key=lambda p: p.stat().st_mtime_ns)
saved = run3d.load(root, files[-1].stem) if files else None
fields = e04.SearchSettings.model_fields
values = {k: v for k, v in ((saved or {}).get("settings") or {}).items() if k in fields}
return e04.SearchSettings(**values).model_dump()
async def step_3d(report: Report, stopped: Stopped) -> str:
src = e04._source_root(root)
stations = await asyncio.to_thread(source.stations, src)
sections = run3d.auto_sections(stations)
search = await asyncio.to_thread(search_settings)
units = [(s, c) for s in sections for c in CONDITIONS_3D]
computed = 0
for index, (section, condition) in enumerate(units):
if stopped():
raise InterruptedError
settings = {
"start_m": round(section["start_m"], 2),
"end_m": round(section["end_m"], 2),
"condition": condition,
**search,
}
key = run3d.section_key(settings["start_m"], settings["end_m"], condition)
label = f"3D {index + 1} / {len(units)} 구간"
finger = await asyncio.to_thread(e04._current_fingerprint, root, src, settings)
saved = await asyncio.to_thread(run3d.load, root, key)
if saved and saved["fingerprint"] == finger:
report((index + 1) / len(units), label)
continue
page = _plug(e04._JOBS, pid, {**e04._IDLE, "running": True, "key": key})
e04._STOPS[pid] = False
def relay(percent: int, message: str, index: int = index, page=page) -> None:
page.update(percent=max(0, min(99, int(percent))), message=message)
report((index + percent / 100) / len(units), label)
try:
page["summary"] = await asyncio.to_thread(
run3d.analyze,
root,
src,
settings,
finger,
relay,
lambda: stopped() or e04._STOPS.get(pid, False),
)
page.update(percent=100, message="끝")
except InterruptedError:
page.update(cancelled=True, message="끊음")
raise
finally:
page["running"] = False
computed += 1
return "done" if computed else "skip"
async def step_rock(_report: Report, _stopped: Stopped) -> str:
out = await asyncio.to_thread(e06._run, root)
return "skip" if out["status"] == "fresh" else "done"
async def step_area(report: Report, _stopped: Stopped) -> str:
src = e07._source_root(root)
saved = await asyncio.to_thread(e07.load, root)
base = (saved or {}).get("settings") or e07.defaults(root)
settings = e07.AreaRequest(**base).model_dump(exclude={"force"})
finger = e07.fingerprint(source.manifest(src)["source_fingerprint"], settings)
if saved and saved["fingerprint"] == finger:
return "skip"
page = _plug(e07._JOBS, pid, {})
def relay(percent: int, message: str) -> None:
page.update(percent=max(0, min(99, int(percent))), message=message)
report(percent / 100, f"광역 {message}")
try:
await asyncio.to_thread(e07.run, root, src, settings, finger, relay)
page.update(percent=100, message="끝")
finally:
page["running"] = False
return "done"
async def step_report(_report: Report, _stopped: Stopped) -> str:
async with e05._lock(project_id):
book, meta = e05_store.load(root)
fresh = await asyncio.to_thread(e05_build.analysis_fingerprint, root)
if book is not None and meta and meta.get("fingerprint") == fresh:
return "skip"
await asyncio.to_thread(e05_store.refresh, root)
return "done"
return {
"2d": step_2d,
"3d": step_3d,
"rock": step_rock,
"area": step_area,
"report": step_report,
}
def busy(project_id: UUID) -> bool:
"""체인 · 페이지 해석 하나라도 도는지 — 한 프로젝트에 체인 하나만."""
from E03_StabSection import E03_StabSection_Router as e03
from E04_Stab3D import E04_Stab3D_Router as e04
from E07_StabArea import E07_StabArea_Router as e07
pid = str(project_id)
return any(
(jobs.get(pid) or {}).get("running") for jobs in (_JOBS, e03._JOBS, e04._JOBS, e07._JOBS)
)
def start(project_id: UUID, project_root: Path) -> dict[str, Any]:
"""뒤에서 체인 시작 — 부르는 쪽이 busy 를 먼저 봄."""
pid = str(project_id)
job = {**idle(), "running": True, "message": "시작"}
_JOBS[pid] = job
_STOPS[pid] = False
steps = build_steps(project_id, project_root)
async def work() -> None:
try:
await run_chain(job, steps, lambda: _STOPS.get(pid, False), pid)
finally:
job["running"] = False
task = asyncio.create_task(work(), name=f"E02 chain {pid}")
_TASKS.add(task)
task.add_done_callback(_TASKS.discard)
return job
def progress(project_id: UUID) -> dict[str, Any]:
return dict(_JOBS.get(str(project_id)) or idle())
def cancel(project_id: UUID) -> bool:
pid = str(project_id)
running = bool((_JOBS.get(pid) or {}).get("running"))
if running:
_STOPS[pid] = True
return running
+37 -6
View File
@@ -5,6 +5,8 @@
- `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json`
· 측점(B06 암 경계)은 서버 값
- `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님)
- `POST …/stab/run` — [해석 시작] 지반 저장 → 한 번에 해석 체인(`E02_StabGround_Chain` · PLAN 564-1)
· `GET …/stab/run/progress` · `POST …/stab/run/cancel`
초기값 = E 생성 직후 마스터 기본값 + 복사본 B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음.
공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음.
@@ -29,6 +31,7 @@ from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
from E01_StabRegister.E01_StabRegister_Source import db_snapshot, missing_stations, source_dir
from E02_StabGround import E02_StabGround_Chain as chain
from E02_StabGround import E02_StabGround_Model as model
router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"])
@@ -130,20 +133,25 @@ async def get_ground(
}
@router.put("/{project_id}/stab/ground")
async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]:
root, _ = await _project(project_id)
def _save(root: Path, payload: dict[str, Any]) -> dict[str, Any]:
"""검사 뒤 작업본 쓰기 — 틀리면 422."""
defaults = model.load_defaults()
errors = model.check_ground(payload.ground, defaults["limits"])
errors = model.check_ground(payload, defaults["limits"])
if errors:
raise HTTPException(status_code=422, detail={"errors": errors})
ground = model.clean(payload.ground)
ground = model.clean(payload)
# 측점(B06 암 경계)은 서버 값 — 브라우저 값을 받아 적지 않음
base = _read_working(root) or ensure_initial(root)
ground["stations"] = base.get("stations", [])
ground["version"] = defaults["version"]
atomic_write_json(model.ground_path(root), ground)
return {"ground": ground}
return ground
@router.put("/{project_id}/stab/ground")
async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]:
root, _ = await _project(project_id)
return {"ground": _save(root, payload.ground)}
@router.post("/{project_id}/stab/ground/reset")
@@ -152,3 +160,26 @@ async def reset_ground(project_id: UUID) -> dict[str, Any]:
initial = ensure_initial(root)
atomic_write_json(model.ground_path(root), initial)
return {"ground": model.upgrade(initial, model.load_defaults())}
@router.post("/{project_id}/stab/run")
async def run_chain(project_id: UUID, payload: GroundRequest) -> dict[str, Any]:
"""[해석 시작] — 지반 저장 → 체인(뒤에서) · 진행은 `GET …/stab/run/progress`."""
root, _ = await _project(project_id)
if chain.busy(project_id):
raise HTTPException(status_code=409, detail="이미 해석 중입니다.")
ground = _save(root, payload.ground)
return {"ground": ground, **chain.start(project_id, root)}
@router.get("/{project_id}/stab/run/progress")
async def run_progress(project_id: UUID) -> dict[str, Any]:
await _project(project_id)
return chain.progress(project_id)
@router.post("/{project_id}/stab/run/cancel")
async def run_cancel(project_id: UUID) -> dict[str, Any]:
"""[끊기] — 다음 확인 때 멈춤 · 끝난 단계 저장본은 그대로."""
await _project(project_id)
return {"stopping": chain.cancel(project_id)}
+131 -18
View File
@@ -2,8 +2,10 @@
* E02_StabGround_UI_Page.ts
* E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow).
* GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지.
* 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) ·
* [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝).
* 조작 = sessionStorage 캐시만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 ·
* 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른
* 페이지에 갔다 와도 이어 보임) · [끊기] · [초기화] = POST …/reset(초기값 덮어씀).
* 검사 = logic/E02_StabGround_Check(파이썬 짝).
* 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05).
* ========================================================================== */
@@ -18,6 +20,8 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { attachHoverInfo } from "@ui/ui_template_hover_info";
import { createListRow } from "@ui/ui_template_list_row";
import { createProgressBar } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
@@ -38,8 +42,33 @@ interface GroundResponse {
missing_stations?: number[];
}
interface RunStep {
key: string;
state: string;
seconds: number | null;
}
/** 체인 진행(`E02_StabGround_Chain` 진행 꼴) */
interface RunProgress {
running: boolean;
percent: number;
steps: RunStep[];
message: string | null;
error: string | null;
cancelled: boolean;
}
const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`;
/** 단계 줄 왼쪽 띠 색 — 기다림 = 없음 */
const STEP_TONE: Record<string, string> = {
run: "var(--color-accent)",
done: "var(--color-success)",
skip: "var(--color-success)",
error: "var(--color-danger)",
cancel: "var(--color-warning)",
};
/** 검사 열쇠 → 범위 열쇠(limits) */
function limitKey(path: string): string {
const last = path.split(".").pop() ?? "";
@@ -56,13 +85,13 @@ function errorText(error: CheckError, defaults: GroundDefaults): string {
return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text;
}
async function request(path: string, init?: RequestInit): Promise<GroundResponse> {
async function request<T = GroundResponse>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...init,
});
const body = (await response.json().catch(() => ({}))) as GroundResponse & { detail?: unknown };
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
if (!response.ok) {
const detail = body.detail;
if (typeof detail === "string") throw new Error(detail);
@@ -76,29 +105,95 @@ export function renderE02StabGround(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const main = el("div", { className: "e02-page" });
const status = el("div", { className: "e02-status" });
const saveButton = createButton({
label: t("Common_Btn_Save"),
const runButton = createButton({
label: t("E02_Run"),
variant: "filled",
onClick: () => void save(),
onClick: () => void runChain(),
});
const resetButton = createButton({
label: t("Common_Btn_Reset"),
variant: "danger-ghost",
onClick: () => void reset(),
});
saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬
runButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, saveButton);
actions.append(resetButton, runButton);
const progressBar = createProgressBar();
const progressText = createText("");
const stepList = el("div", { className: "e02-run__steps" });
const cancelButton = createButton({
label: t("E02_Run_Cancel"),
variant: "danger-ghost",
size: "sm",
onClick: () => void cancelChain(),
});
const runBox = el("div", { className: "e02-run" });
runBox.append(progressBar.root, progressText, stepList, cancelButton);
runBox.hidden = true;
const panel = el("div", { className: "ui-sidebar-panel e02-panel" });
panel.append(hook(createText(t("E02_Note")), "e02-hint"), status, actions);
panel.append(
hook(createText(t("E02_Note")), "e02-hint"),
hook(createText(t("E02_Run_Hint")), "e02-hint"),
status,
runBox,
actions,
);
mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel });
if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty"));
let view: E02View | null = null;
let refreshStations = (): void => {};
let dirty = false;
let running = false;
let missing: number[] = [];
const paintButtons = (): void => {
runButton.disabled = resetButton.disabled = !view?.editable || running;
};
const paintRun = (p: RunProgress): void => {
running = p.running;
runBox.hidden = !p.running && p.steps.every((s) => s.state === "wait");
progressBar.set(p.percent / 100, `${p.percent}%`);
progressText.textContent = p.running ? `${t("E02_Run_Running")} · ${p.message ?? ""}` : "";
stepList.replaceChildren(
...p.steps.map((s) =>
createListRow({
label: t(`E02_Run_Step_${s.key}` as LocaleKey),
trailing: [
t(`E02_Run_State_${s.state}` as LocaleKey),
s.seconds !== null && s.state !== "wait" ? `${s.seconds} s` : "",
]
.filter(Boolean)
.join(" · "),
stripe: STEP_TONE[s.state] ?? "transparent",
active: s.state === "run",
size: 28,
}),
),
);
cancelButton.hidden = !p.running;
paintButtons();
};
/** 체인이 끝날 때까지 — 다른 페이지로 가면 멈춤(체인은 서버에서 계속 · 다시 오면 이어 봄) */
async function poll(): Promise<void> {
while (root.isConnected) {
const p = await request<RunProgress>(`/projects/${projectId}/stab/run/progress`).catch(
() => null,
);
if (!p) return;
paintRun(p);
if (!p.running) {
if (p.error) showToast(p.error, "error");
else if (p.cancelled) showToast(t("E02_Run_Cancelled"), "info");
else showToast(t("E02_Run_Done"), "success");
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
const paintStatus = (errors: CheckError[]): void => {
const lines: string[] = [];
if (view && !view.editable) lines.push(t("E02_ReadOnly"));
@@ -158,12 +253,13 @@ export function renderE02StabGround(root: HTMLElement): void {
view.ground = ground;
view.defaults = body.defaults;
view.editable = body.editable ?? false;
saveButton.disabled = resetButton.disabled = !view.editable;
paintButtons();
render();
};
async function save(): Promise<void> {
if (!view?.editable) return;
/** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */
async function runChain(): Promise<void> {
if (!view?.editable || running) return;
const errors = mark();
if (errors.length) {
showToast(
@@ -174,16 +270,30 @@ export function renderE02StabGround(root: HTMLElement): void {
}
showLoadingOverlay();
try {
const body = await request(`/projects/${projectId}/stab/ground`, {
method: "PUT",
const body = await request<GroundResponse & RunProgress>(`/projects/${projectId}/stab/run`, {
method: "POST",
body: JSON.stringify({ ground: view.ground }),
});
adopt({ ...body, defaults: view.defaults, editable: view.editable }, false);
showToast(t("E02_Saved"), "success");
adopt({ ground: body.ground, defaults: view.defaults, editable: view.editable }, false);
paintRun(body);
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
return;
} finally {
hideLoadingOverlay();
}
void poll();
}
async function cancelChain(): Promise<void> {
cancelButton.disabled = true;
try {
await request(`/projects/${projectId}/stab/run/cancel`, { method: "POST" });
showToast(t("E02_Run_Stopping"), "info");
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
} finally {
hideLoadingOverlay();
cancelButton.disabled = false;
}
}
@@ -246,6 +356,9 @@ export function renderE02StabGround(root: HTMLElement): void {
},
};
adopt(body, true);
const p = await request<RunProgress>(`/projects/${projectId}/stab/run/progress`);
paintRun(p);
if (p.running) void poll();
} catch (error) {
main.replaceChildren(
hook(
@@ -121,3 +121,20 @@
gap: 2px;
align-items: center;
}
/* [해석 시작] 진행 — 막대 · 단계 줄 · [끊기] */
.e02-run {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-8) 0;
}
.e02-run[hidden] {
display: none;
}
.e02-run__steps {
display: flex;
flex-direction: column;
}
+5 -2
View File
@@ -9,6 +9,7 @@ import { createIconButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
import { e03Table } from "./E03_StabSection_UI_Table";
import {
listRows,
@@ -29,7 +30,9 @@ export interface ListDraw {
sort: SortMode;
ngOnly: boolean;
active: string | null;
/** 사용자 켬/끔 — 저장 전 캐시 포함 · 없으면 서버 값 */
/** 측점 간격(m) — 측점 글 「No.+거리」 의 분모 */
interval: number;
/** 사용자 켬/끔 — 서버 값(별 누름 = 바로 저장) */
representative: (row: StationRow) => { on: boolean; user: boolean | null };
editable: boolean;
}
@@ -81,7 +84,7 @@ export function buildList(
);
verdict.classList.add("e03-verdict", `e03-verdict--${v}`);
const station = el("span", {
text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key,
text: stationLabel(row.chainage_m, draw.interval, 1),
children: row.walls
? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })]
: [],
+66 -44
View File
@@ -24,7 +24,8 @@ import { t, type LocaleKey } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing";
import { buildList } from "./E03_StabSection_UI_List";
import { buildPlan } from "./E03_StabSection_UI_Plan";
import { buildPlan, type PlanView } from "./E03_StabSection_UI_Plan";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts";
import { buildResult } from "./E03_StabSection_UI_Result";
import {
@@ -84,7 +85,6 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
return body;
}
const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
export function renderE03StabSection(root: HTMLElement): void {
@@ -111,9 +111,9 @@ export function renderE03StabSection(root: HTMLElement): void {
const slopeRecs = new Map<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
const wallRecs = new Map<string, WallRecord>();
const supportRecs = new Map<string, SupportRecord>();
let repCache: Record<string, boolean | null> = JSON.parse(
sessionStorage.getItem(repKey(pid)) ?? "{}",
);
let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390)
const planView: PlanView = {};
let repSaving: Promise<void> = Promise.resolve(); // 별 누름마다 차례로 저장
// ---- 좌측 ----
const condField = createSelectField({
@@ -158,15 +158,10 @@ export function renderE03StabSection(root: HTMLElement): void {
variant: "ghost",
onClick: () => void show3d(),
});
const repButton = createButton({
label: t("E03_RepSave"),
variant: "ghost",
onClick: () => void saveRep(),
});
const controls = el("div", { className: "e03-panel__row" });
controls.append(condField.root, sortField.root);
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(repButton, view3dButton, analyzeButton);
actions.append(view3dButton, analyzeButton);
panel.append(controls, ngBox, status, listHost, actions);
// ---- 본문 ----
@@ -187,11 +182,8 @@ export function renderE03StabSection(root: HTMLElement): void {
const repOf = (row: StationRow): { on: boolean; user: boolean | null } => {
const server = row.representative ?? { auto: [], user: null, on: false };
if (!(row.key in repCache)) return { on: server.on, user: server.user };
const user = repCache[row.key];
return { on: user === null ? server.auto.length > 0 : user, user };
return { on: server.on, user: server.user };
};
const repDirty = (): boolean => Object.keys(repCache).length > 0;
const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active);
@@ -217,10 +209,8 @@ export function renderE03StabSection(root: HTMLElement): void {
if (!result.fresh) lines.push(t("E03_Stale"));
}
if (result && !result.editable) lines.push(t("E03_ReadOnly"));
if (repDirty()) lines.push(t("E03_RepUnsaved"));
status.textContent = lines.join("\n");
analyzeButton.disabled = !result?.editable;
repButton.disabled = !result?.editable || !repDirty();
view3dButton.disabled = result?.status !== "ok";
};
@@ -235,22 +225,12 @@ export function renderE03StabSection(root: HTMLElement): void {
sort,
ngOnly,
active,
interval,
representative: repOf,
editable: result.editable,
},
(key) => void select(key),
(key) => {
const row = result!.stations.find((r) => r.key === key)!;
const now = repOf(row).on;
const auto = (row.representative?.auto.length ?? 0) > 0;
// 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌
const next = !now === auto ? null : !now;
if (next === (row.representative?.user ?? null)) delete repCache[key];
else repCache[key] = next;
sessionStorage.setItem(repKey(pid), JSON.stringify(repCache));
paintList();
paintStatus();
},
(key) => void toggleRep(key),
),
);
};
@@ -286,7 +266,7 @@ export function renderE03StabSection(root: HTMLElement): void {
});
const title = el("h3", {
className: "e03-card__title",
text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
text: `${stationLabel(view.chainage_m, interval, 1)} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
});
const flags = el("div", { className: "e03-panel__row" });
flags.append(
@@ -399,7 +379,16 @@ export function renderE03StabSection(root: HTMLElement): void {
const paintPlan = (): void => {
planHost.replaceChildren(
result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"),
result
? buildPlan(
result.stations,
condition,
active,
interval,
planView,
(key) => void select(key),
)
: el("div"),
);
};
@@ -438,7 +427,10 @@ export function renderE03StabSection(root: HTMLElement): void {
condition,
);
const keys = result.stations.map((r) => r.key);
repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k)));
interval = inferStationInterval(
result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m),
);
delete planView.zoom; // 다시 읽음 = 띠 원배율
if (!active || !keys.includes(active)) {
// 처음 = 그 조건 가장 낮은 안전율 측점
const worst = result.stations
@@ -488,20 +480,50 @@ export function renderE03StabSection(root: HTMLElement): void {
}
}
async function saveRep(): Promise<void> {
try {
/** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */
function toggleRep(key: string): Promise<void> {
repSaving = repSaving.then(async () => {
const row = result?.stations.find((r) => r.key === key);
if (!row) return;
const server = row.representative ?? { auto: [], user: null, on: false };
const auto = server.auto.length > 0;
const next = !server.on === auto ? null : !server.on;
const overrides: Record<string, boolean | null> = {};
for (const row of result?.stations ?? []) {
const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null);
if (user !== null) overrides[row.key] = user;
for (const r of result?.stations ?? []) {
const user = r.key === key ? next : (r.representative?.user ?? null);
if (user !== null) overrides[r.key] = user;
}
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
repCache = {};
sessionStorage.removeItem(repKey(pid));
showToast(t("E03_RepSaved"), "success");
await load();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
try {
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
row.representative = { ...server, user: next, on: next === null ? auto : next };
paintList();
void refreshReport();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
});
return repSaving;
}
/** 보고서 갱신 — E05 [보고서 갱신] 과 같은 길(GET 이 작업본 없으면 처음 만듦 · 뒤에서) */
let reportRun = 0;
async function refreshReport(): Promise<void> {
const run = ++reportRun;
await sleep(800); // 별을 잇달아 누르면 마지막 한 번만
if (run !== reportRun) return;
try {
const state = await api<{ book: unknown; rev: string | null }>(
`/projects/${pid}/stab/report`,
);
if (!state.book || run !== reportRun) return;
const body = await api<{ overwritten?: string[] }>(`/projects/${pid}/stab/report/refresh`, {
method: "POST",
body: JSON.stringify({ rev: state.rev }),
});
const over = body.overwritten ?? [];
if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
} catch {
/* 보고서는 E05 를 열 때 stale 로 알림 */
}
}
+113 -35
View File
@@ -3,14 +3,20 @@
* E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이
* 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점.
* 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`.
* 확대 · 이동 = 공용 `attachSvgZoomPan`(휠 · 가운데 끌기 · 두 번 누름 원배율 · 요구 390) · 측점 글 솎음
* = logic `labelStride` · `planLabelKeys` · 배율 · 폭은 바깥 `PlanView` 가 쥠(다시 지어도 살아남음).
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { stationLabel } from "../common_util/common_util_station";
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import {
bandSegments,
labelStride,
planFit,
planLabelKeys,
verdictOf,
type Condition,
type Point,
@@ -21,11 +27,20 @@ import { svgEl } from "./E03_StabSection_UI_Section";
const H = 96;
const PAD = 10;
const LABEL_GAP = 64; // 측점 글 사이 최소 화면 간격(px)
/** 띠 배율 · 그때 폭 — 폭이 바뀌면 원배율로 */
export interface PlanView {
zoom?: ZoomPanState;
width?: number;
}
export function buildPlan(
rows: StationRow[],
condition: Condition,
active: string | null,
interval: number,
state: PlanView,
onSelect: (key: string) => void,
): HTMLElement {
const box = card("e03-plan");
@@ -49,47 +64,110 @@ export function buildPlan(
const host = el("div");
box.append(host);
const ro = new ResizeObserver(() => draw());
const draw = (): void => {
if (!host.isConnected) return void ro.disconnect(); // 다시 지은 띠 — 옛 것은 손 뗌
const W = Math.max(host.clientWidth, 300);
const segs = bandSegments(rows, condition);
const all: Point[] = segs.flatMap((s) => s.points);
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" });
if (all.length) {
const fit = planFit(all, W, H, PAD);
const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
for (const seg of segs) {
svg.append(
svgEl("polyline", {
points: seg.points.map((p) => scr(p).join(",")).join(" "),
class: `e03-plan__seg e03-plan__seg--${seg.verdict}`,
"data-verdict": seg.verdict,
}),
);
}
const centers = rows.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null);
const picked = centers.find((r) => r.key === active);
if (picked) {
const [x, y] = scr([picked.center![0]!, picked.center![1]!]);
svg.append(
svgEl("circle", { cx: x, cy: y, r: 6, class: "e03-plan__pick", "data-key": picked.key }),
);
}
svg.addEventListener("click", (ev) => {
const box2 = svg.getBoundingClientRect();
const px = ((ev.clientX - box2.left) / box2.width) * W;
const py = ((ev.clientY - box2.top) / box2.height) * H;
let best: StationRow | null = null;
let dist = Infinity;
for (const r of centers) {
const [x, y] = scr([r.center![0]!, r.center![1]!]);
const d = Math.hypot(x - px, y - py);
if (d < dist) [best, dist] = [r, d];
}
if (best && dist < 30) onSelect(best.key);
});
}
host.replaceChildren(svg);
if (!all.length) return;
const fit = planFit(all, W, H, PAD);
const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
const layer = svgEl("g", { "data-role": "plan-layer" }) as SVGGElement;
for (const seg of segs) {
layer.append(
svgEl("polyline", {
points: seg.points.map((p) => scr(p).join(",")).join(" "),
class: `e03-plan__seg e03-plan__seg--${seg.verdict}`,
"data-verdict": seg.verdict,
}),
);
}
const centers = rows
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
.sort((a, b) => a.chainage_m - b.chainage_m);
const at = (r: StationRow): Point => scr([r.center![0]!, r.center![1]!]);
// 측점 간격 하나의 화면 길이(원배율) — 중심 이은 길이 ÷ 누가 길이 × 간격
const span =
centers.length > 1 ? centers[centers.length - 1].chainage_m - centers[0].chainage_m : 0;
let len = 0;
for (let i = 1; i < centers.length; i += 1) {
const [a, b] = [at(centers[i - 1]), at(centers[i])];
len += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
const intervalPx = span > 0 ? (len / span) * interval : 0;
const maxScale = Math.min(500, Math.max(8, Math.ceil(240 / Math.max(intervalPx, 1e-3))));
const picked = centers.find((r) => r.key === active);
const pick = picked
? svgEl("circle", {
cx: at(picked)[0],
cy: at(picked)[1],
r: 6,
class: "e03-plan__pick",
"data-key": picked.key,
})
: null;
const labels = svgEl("g", { class: "e03-plan__labels" }) as SVGGElement;
layer.append(labels, ...(pick ? [pick] : []));
svg.append(layer);
let stride = 0;
const apply = (s: ZoomPanState): void => {
state.zoom = s;
labels.style.setProperty("--e03-label-k", String(1 / s.scale)); // 글 · 표시는 확대 안 함
pick?.setAttribute("r", String(6 / s.scale));
const next = intervalPx > 0 ? labelStride(intervalPx * s.scale, LABEL_GAP) : 100;
if (next === stride) return;
stride = next;
const keep = new Set(planLabelKeys(centers, interval, stride, active));
labels.replaceChildren(
...centers
.filter((r) => keep.has(r.key))
.map((r) =>
svgEl(
"text",
{
x: at(r)[0],
y: at(r)[1],
dy: "-0.9em",
class: "e03-label e03-plan__label",
"data-key": r.key,
},
stationLabel(r.chainage_m, interval, 1),
),
),
);
};
const initial = state.width === W ? state.zoom : undefined;
state.width = W;
attachSvgZoomPan({
svg,
layer,
widthPx: W,
heightPx: H,
pad: { left: 0, right: 0, top: 0, bottom: 0 },
wheelZoom: true,
maxScale,
initial,
onChange: apply,
});
apply(initial ?? { scale: 1, tx: 0, ty: 0 });
svg.addEventListener("click", (ev) => {
const box2 = svg.getBoundingClientRect();
const px = ((ev.clientX - box2.left) / box2.width) * W;
const py = ((ev.clientY - box2.top) / box2.height) * H;
const s = state.zoom ?? { scale: 1, tx: 0, ty: 0 };
let best: StationRow | null = null;
let dist = Infinity;
for (const r of centers) {
const [x, y] = at(r);
const d = Math.hypot(s.scale * x + s.tx - px, s.scale * y + s.ty - py);
if (d < dist) [best, dist] = [r, d];
}
if (best && dist < 30) onSelect(best.key);
});
};
new ResizeObserver(draw).observe(host);
ro.observe(host);
return box;
}
+8 -1
View File
@@ -78,7 +78,7 @@
/* ---- 틀 ---- */
.e03-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
gap: var(--spacing-8);
height: calc(100dvh - var(--app-header-h));
padding: var(--spacing-8) var(--spacing-12);
@@ -207,6 +207,7 @@
.e03-plan__seg {
fill: none;
stroke-width: 7;
vector-effect: non-scaling-stroke;
stroke-linecap: butt;
stroke-linejoin: round;
cursor: pointer;
@@ -228,6 +229,12 @@
fill: var(--e03-plot);
stroke: var(--e03-critical);
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
.e03-plan__label {
text-anchor: middle;
pointer-events: none;
}
.e03-legend {
@@ -190,6 +190,31 @@ export function planFit(
return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 };
}
/** 평면 띠 측점 글 솎음 걸음 — 측점 간격 화면 길이(px) × 걸음이 minPx 넘는 가장 작은 것 */
export const PLAN_LABEL_STRIDES = [1, 2, 5, 10, 25, 50, 100] as const;
export function labelStride(intervalPx: number, minPx: number): number {
return PLAN_LABEL_STRIDES.find((s) => s * intervalPx >= minPx) ?? 100;
}
/** 측점 글 붙일 열쇠 — 본 측점(간격 배수) 중 걸음 배수 · 걸음 1 이면 보조 측점도 · 고른 측점은 늘 */
export function planLabelKeys(
rows: StationRow[],
interval: number,
stride: number,
active: string | null,
): string[] {
const step = interval > 0 ? interval : 1;
return rows
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
.filter((r) => {
if (r.key === active || stride === 1) return true;
const n = Math.round(r.chainage_m / step);
return Math.abs(r.chainage_m - n * step) < 0.05 && n % stride === 0;
})
.map((r) => r.key);
}
/* ---- 안전율 색 ------------------------------------------------------------ */
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
+4 -3
View File
@@ -5,7 +5,7 @@
- `POST …/stab/analyze3d/cancel` — 도는 해석 끊기(다시 시작 = analyze3d 다시)
- `GET …/stab/progress3d` — 진행률
- `GET …/stab/result3d` — 자동 구간 · 노선 · 측점 · 저장본 요약(+ 지문이 지금과 다른지)
- `GET …/stab/result3d?key=` — 한 구간 저장본(래스터 · 임계 활동면)
- `GET …/stab/result3d?key=` — 한 구간 · 조건 저장본(래스터 · 임계 활동면) · 열쇠 = `시작_끝_조건`
계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company`(지정받은 회사 = GET 만 · 그 밖 403).
"""
@@ -14,6 +14,7 @@ from __future__ import annotations
import asyncio
import logging
import re
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
@@ -126,7 +127,7 @@ async def analyze3d(project_id: UUID, payload: Analyze3dRequest) -> dict[str, An
project_root = await _project_root(project_id)
root = _source_root(project_root)
settings = _settings(payload)
key = run.section_key(settings["start_m"], settings["end_m"])
key = run.section_key(settings["start_m"], settings["end_m"], settings["condition"])
finger = await asyncio.to_thread(_current_fingerprint, project_root, root, settings)
saved = await asyncio.to_thread(run.load, project_root, key)
if not payload.force and saved and saved["fingerprint"] == finger:
@@ -229,7 +230,7 @@ async def result3d(
overview = await asyncio.to_thread(_overview, project_root, root)
editable = await _editable(project_id, session)
return {"status": "success", "editable": editable, **overview}
if not all(ch.isdigit() or ch in "._" for ch in key):
if not re.fullmatch(r"\d+\.\d{2}_\d+\.\d{2}(_(dry|wet))?", key):
raise HTTPException(status_code=400, detail="구간 열쇠가 틀렸습니다.")
doc = await asyncio.to_thread(run.load, project_root, key)
if doc is None:
+61 -21
View File
@@ -1,8 +1,9 @@
"""E04 3D 해석 한 번 — 입력 모으기 · 입력 지문 · 엔진 부르기 · 파생 저장본 (PLAN 533-3).
계산 자리 = ③ 서버 단독. 엔진(탐색 · 기둥 솔버 · 합성 지형) 주인 = 533-1 · 2 — 여기는 부르기만.
저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 입력 지문(복사본 지문 · E02 지반 ·
요청 · 엔진 판)이 같으면 다시 셈하지 않음.
저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 열쇠 = `시작_끝_조건`
(예 `0.00_100.00_wet` · 옛 `시작_끝` 파일은 그 안 조건으로 읽음) · 입력 지문(복사본 지문 ·
E02 지반 · 요청 · 엔진 판 = 엔진 파일 해시)이 같으면 다시 셈하지 않음.
래스터 꼴(화면 `logic/E04_Stab3D_Raster.ts` 와 같은 열쇠):
grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열
@@ -22,6 +23,7 @@ import math
import time
from collections.abc import Callable
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
@@ -34,7 +36,13 @@ from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA
from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine
from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine
ENGINE_VERSION = "e04-4" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈
ENGINE_FILES = (
"E04_Stab3D_Engine_Column.py",
"E04_Stab3D_Engine_Grid.py",
"E04_Stab3D_Engine_Search.py",
"E04_Stab3D_Run.py",
)
CONDITIONS = ("dry", "wet")
STAGE_DIR = "E04_Stab3D"
SECTION_LENGTH_M = 100.0 # 자동 구간 길이
MAX_SECTION_M = 500.0 # 한 번에 셀 구간 상한(시간 · 메모리)
@@ -48,8 +56,23 @@ def results_dir(project_root: Path) -> Path:
return Path(project_root) / STAGE_DIR / "results"
def section_key(start_m: float, end_m: float) -> str:
return f"{start_m:.2f}_{end_m:.2f}"
@lru_cache(maxsize=1)
def engine_hash() -> str:
"""엔진 판 = 엔진 코드 지문 — 코드가 바뀌면 지문이 달라져 다시 셈."""
digest = hashlib.sha1()
for name in ENGINE_FILES:
digest.update((Path(__file__).parent / name).read_bytes())
return digest.hexdigest()[:12]
def section_key(start_m: float, end_m: float, condition: str = "dry") -> str:
return f"{start_m:.2f}_{end_m:.2f}_{condition}"
def _doc_key(doc: dict[str, Any]) -> str:
summary = doc["summary"]
condition = summary.get("condition") or doc["settings"].get("condition") or "dry"
return section_key(float(summary["start_m"]), float(summary["end_m"]), condition)
def auto_sections(stations: list[dict[str, Any]]) -> list[dict[str, float]]:
@@ -85,7 +108,7 @@ def fingerprint(
"source": source_fingerprint,
"ground": ground,
"settings": settings,
"engine": ENGINE_VERSION,
"engine": engine_hash(),
},
ensure_ascii=False,
sort_keys=True,
@@ -308,7 +331,7 @@ def pack(
)
return {
"fingerprint": finger,
"engine": ENGINE_VERSION,
"engine": engine_hash(),
"settings": settings,
"computed_at": datetime.now().isoformat(timespec="seconds"),
"elapsed_s": round(elapsed_s, 2),
@@ -346,24 +369,37 @@ def save(project_root: Path, key: str, result: dict[str, Any]) -> None:
def load(project_root: Path, key: str) -> dict[str, Any] | None:
path = results_dir(project_root) / f"{key}.json"
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None
"""열쇠 파일 → 없으면 옛 이름(`시작_끝`) 파일이 같은 조건일 때."""
folder = results_dir(project_root)
path = folder / f"{key}.json"
if path.is_file():
return json.loads(path.read_text(encoding="utf-8"))
base, _, condition = key.rpartition("_")
legacy = folder / f"{base}.json"
if condition in CONDITIONS and legacy.is_file():
doc = json.loads(legacy.read_text(encoding="utf-8"))
if _doc_key(doc) == key:
return doc
return None
def saved_summaries(project_root: Path) -> list[dict[str, Any]]:
"""저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용."""
out = []
"""저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용.
열쇠 = 구간 + 조건 · 같은 열쇠면 새 이름 파일이 옛 이름 파일을 이김."""
found: dict[str, dict[str, Any]] = {}
for path in sorted(results_dir(project_root).glob("*.json")):
doc = json.loads(path.read_text(encoding="utf-8"))
out.append(
{
"key": path.stem,
"fingerprint": doc["fingerprint"],
"settings": doc["settings"],
**doc["summary"],
}
)
return out
key = _doc_key(doc)
if key in found and path.stem != key:
continue
found[key] = {
"key": key,
"fingerprint": doc["fingerprint"],
"settings": doc["settings"],
**doc["summary"],
}
return [found[k] for k in sorted(found)]
def analyze(
@@ -385,5 +421,9 @@ def analyze(
ground = read_ground(project_root)
raw = run_engine(root, around, ground, settings, on_progress, should_stop)
result = pack(raw, picked, settings, finger, time.perf_counter() - started)
save(project_root, section_key(settings["start_m"], settings["end_m"]), result)
save(
project_root,
section_key(settings["start_m"], settings["end_m"], settings["condition"]),
result,
)
return result["summary"]
+50 -34
View File
@@ -4,7 +4,8 @@
* 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기]
* 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면)
* 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법)
* 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage.
* 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage
* · 없으면 저장된 결과의 설정 · 결과 열쇠 = 구간 + 조건(건기 · 우기 따로 남음).
* ========================================================================== */
import "./E04_Stab3D_UI_Style.css";
@@ -44,6 +45,7 @@ interface SavedSummary {
f_min: number;
volume: number;
method: string;
condition: string;
stale: boolean;
}
@@ -67,7 +69,10 @@ interface Progress {
}
const cacheKey = (projectId: string): string => `e04_settings:${projectId}`;
const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`;
const CONDITIONS = ["dry", "wet"] as const;
const rangeKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`;
const sectionKey = (start: number, end: number, condition: string): string =>
`${rangeKey(start, end)}_${condition}`;
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
@@ -218,16 +223,11 @@ export function renderE04Stab3D(root: HTMLElement): void {
const analyzeButton = createButton({
label: t("E04_Analyze"),
variant: "filled",
onClick: () => void analyze(false),
onClick: () => void analyze(),
});
const reanalyzeButton = createButton({
label: t("E04_Reanalyze"),
variant: "ghost",
onClick: () => void analyze(true),
});
analyzeButton.disabled = reanalyzeButton.disabled = true;
analyzeButton.disabled = true;
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(reanalyzeButton, analyzeButton);
actions.append(analyzeButton);
const readOnly = note(t("E04_ReadOnly"), "e04-hint");
readOnly.hidden = true;
panel.append(
@@ -252,40 +252,46 @@ export function renderE04Stab3D(root: HTMLElement): void {
let shownKey = "";
settings.onChange(() => {
remember();
const { start_m, end_m } = settings.read();
const { start_m, end_m, condition } = settings.read();
map.setRange(start_m, end_m);
paintSections();
if (shownKey !== sectionKey(start_m, end_m)) void openResult();
if (shownKey !== sectionKey(start_m, end_m, condition)) void openResult();
});
function paintButtons(): void {
const editable = Boolean(overview?.editable);
analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview;
analyzeButton.disabled = !editable || running || !overview;
readOnly.hidden = !overview || editable;
}
function paintSections(): void {
if (!overview) return;
const { start_m, end_m } = settings.read();
const { start_m, end_m, condition } = settings.read();
const savedOf = new Map(overview.saved.map((s) => [s.key, s]));
const custom = overview.saved.filter(
(s) => !overview!.sections.some((x) => sectionKey(x.start_m, x.end_m) === s.key),
);
const items = [
...overview.sections,
...custom.map((s) => ({ start_m: s.start_m, end_m: s.end_m })),
];
const items = new Map(overview.sections.map((x) => [rangeKey(x.start_m, x.end_m), x]));
for (const s of overview.saved) {
const key = rangeKey(s.start_m, s.end_m);
if (!items.has(key)) items.set(key, { start_m: s.start_m, end_m: s.end_m });
}
sectionList.replaceChildren(
...items.map((section) => {
const key = sectionKey(section.start_m, section.end_m);
const saved = savedOf.get(key);
...[...items].map(([key, section]) => {
const byCondition = CONDITIONS.map((c) => savedOf.get(`${key}_${c}`));
const saved = savedOf.get(`${key}_${condition}`);
const row = createListRow({
label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`,
trailing: saved
? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}`
: "",
trailing: byCondition
.flatMap((x) =>
x
? [
`${t(`E04_Short_${x.condition}` as LocaleKey)} ${x.f_min.toFixed(2)}${
x.stale ? ` ${t("E04_Stale")}` : ""
}`,
]
: [],
)
.join(" · "),
stripe: saved ? stepColor(FS_STEPS, saved.f_min) : "transparent",
active: key === sectionKey(start_m, end_m),
active: key === rangeKey(start_m, end_m),
onClick: () => settings.setRange(section.start_m, section.end_m),
});
row.classList.add("e04-section");
@@ -364,15 +370,19 @@ export function renderE04Stab3D(root: HTMLElement): void {
resultBody.replaceChildren(list, ...notes);
}
const currentKey = (): string => {
const { start_m, end_m, condition } = settings.read();
return sectionKey(start_m, end_m, condition);
};
async function openResult(): Promise<void> {
const { start_m, end_m } = settings.read();
const key = sectionKey(start_m, end_m);
const key = currentKey();
shownKey = key;
const saved = overview?.saved.some((s) => s.key === key);
result = saved
? await api<Result3d>(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null)
: null;
if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜
if (key !== currentKey()) return; // 그새 바뀜
map.setResult(result);
if (mode === "3d") view3d.show(result, overview?.line ?? [], layer);
paintResult();
@@ -405,13 +415,13 @@ export function renderE04Stab3D(root: HTMLElement): void {
}
}
async function analyze(force: boolean): Promise<void> {
async function analyze(): Promise<void> {
const values = settings.read();
const { start_m, end_m, condition, ...search } = values;
try {
const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, {
method: "POST",
body: JSON.stringify({ start_m, end_m, condition, search, force }),
body: JSON.stringify({ start_m, end_m, condition, search }),
});
if (body.status === "cached") {
showToast(t("E04_Cached"), "info");
@@ -446,7 +456,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
...ov.defaults,
start_m: first?.start_m ?? 0,
end_m: first?.end_m ?? 100,
condition: "dry",
condition: ov.saved[0]?.condition ?? "dry",
},
);
map.setRoute(ov.line, ov.stations);
@@ -455,6 +465,12 @@ export function renderE04Stab3D(root: HTMLElement): void {
paintSections();
stations2d = await fetch2d(projectId);
await openResult();
// 캐시가 없으면 저장된 결과의 탐색 설정을 되살림
const opened = result as Result3d | null; // openResult 가 바꿈(흐름 좁힘 풀기)
if (!cached && opened?.settings) {
settings.write(opened.settings as Partial<Settings>);
remember();
}
const p = await api<Progress>(`/projects/${projectId}/stab/progress3d`).catch(() => null);
if (p?.running) void poll();
})();
+2 -2
View File
@@ -98,8 +98,8 @@ export function buildSettingsCards() {
variant: "ghost",
size: "sm",
onClick: () => {
const range = { start_m: read().start_m, end_m: read().end_m };
write({ ...read(), ...defaults, ...range, condition: "dry" });
const { start_m, end_m, condition } = read(); // 구간 · 조건은 그대로
write({ ...read(), ...defaults, start_m, end_m, condition });
changed();
},
}),
@@ -411,6 +411,9 @@ process.stdout.write(JSON.stringify({
stress: m.sliceStress([input.slice]),
ticks: m.niceTicks(-3, 47, 5),
zAt: [m.zAt([[0, 0], [10, 10]], 2.5), m.zAt([[10, 10], [0, 0]], -5)],
stride: [m.labelStride(100, 64), m.labelStride(40, 64), m.labelStride(10, 64), m.labelStride(0.1, 64)],
labels: [1, 2, 5].map((k) => m.planLabelKeys(input.rows, 20, k, null)),
labelsActive: m.planLabelKeys(input.rows, 20, 5, "e"),
}));
"""
@@ -541,6 +544,10 @@ def test_logic_TS():
)
assert out["ticks"][0] <= -3 and out["ticks"][-1] >= 47 and 0 in out["ticks"]
assert out["zAt"] == [2.5, 0]
# 평면 띠 측점 글 솎음 — 간격 px × 걸음 ≥ 64 · 본 측점 중 걸음 배수 · 고른 측점은 늘
assert out["stride"] == [1, 2, 10, 100]
assert out["labels"] == [["a", "b", "c", "d", "e"], ["a", "c", "e"], ["a"]]
assert out["labelsActive"] == ["a", "e"]
def test_엔진_지문은_코드_내용():
+39 -4
View File
@@ -107,7 +107,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch):
monkeypatch.setattr(run, "run_engine", counted)
first = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json()
assert first == {"status": "accepted", "key": "0.00_100.00"}
assert first == {"status": "accepted", "key": "0.00_100.00_dry"}
done = _wait(client)
assert done["error"] is None and done["percent"] == 100
assert done["summary"]["cells"] == 5 and done["summary"]["verdict"] == "ng"
@@ -133,7 +133,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch):
_wait(client)
assert len(calls) == 3
doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).json()
doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"}).json()
grid = doc["grid"]
assert len(doc["f"]) == len(doc["z"]) == len(doc["v"]) == grid["nx"] * grid["ny"]
f = np.array([np.nan if v is None else v for v in doc["f"]])
@@ -143,7 +143,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch):
assert doc["stale"] is False
over = client.get(f"/api/projects/{PID}/stab/result3d").json()
assert [s["key"] for s in over["saved"]] == ["0.00_100.00"] and over["editable"] is True
assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry"] and over["editable"] is True
assert len(over["line"]) == len(STATIONS) and over["sections"][0]["end_m"] == 100.0
# E02 지반을 바꾸면 저장본이 「옛 것」
(project / "E02_StabGround").mkdir()
@@ -179,7 +179,9 @@ def test_cancel_and_restart(client, monkeypatch):
body = _wait(client)
assert body["cancelled"] is True and body["error"] is None
assert (
client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).status_code
client.get(
f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"}
).status_code
== 404
)
# 다시 시작
@@ -191,6 +193,39 @@ def test_cancel_and_restart(client, monkeypatch):
assert body["cancelled"] is False and body["percent"] == 100
def test_dry_then_wet_both_kept(client, project, monkeypatch):
"""건기 → 우기 = 다른 열쇠 · 앞 결과가 덮이지 않음 · 옛 이름(조건 없음) 파일도 읽음."""
monkeypatch.setattr(run, "run_engine", _fake_engine)
for condition in ("dry", "wet"):
body = client.post(
f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": condition}
).json()
assert body == {"status": "accepted", "key": f"0.00_100.00_{condition}"}
_wait(client)
over = client.get(f"/api/projects/{PID}/stab/result3d").json()
assert [(s["key"], s["condition"]) for s in over["saved"]] == [
("0.00_100.00_dry", "dry"),
("0.00_100.00_wet", "wet"),
]
folder = run.results_dir(project)
# 옛 이름 — 우기 파일을 조건 없는 이름으로 옮기면 우기 열쇠로 읽힘
(folder / "0.00_100.00_wet.json").rename(folder / "0.00_100.00.json")
over = client.get(f"/api/projects/{PID}/stab/result3d").json()
assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry", "0.00_100.00_wet"]
url = f"/api/projects/{PID}/stab/result3d"
assert client.get(url, params={"key": "0.00_100.00_wet"}).json()["stale"] is False
again = client.post(
f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": "wet"}
).json()
assert again["status"] == "cached"
def test_engine_hash_in_fingerprint(monkeypatch):
before = run.fingerprint("a", None, {})
monkeypatch.setattr(run, "engine_hash", lambda: "changed")
assert run.fingerprint("a", None, {}) != before
def test_bad_requests(client):
url = f"/api/projects/{PID}/stab/analyze3d"
assert client.post(url, json={"start_m": 100, "end_m": 50}).status_code == 400
+1 -1
View File
@@ -170,7 +170,7 @@ def project(tmp_path, monkeypatch) -> Path:
path.write_text(json.dumps(ground), encoding="utf-8")
r3 = root / "E04_Stab3D" / "results"
r3.mkdir(parents=True)
(r3 / "0.00_200.00.json").write_text(
(r3 / "0.00_200.00_wet.json").write_text(
json.dumps(
{
"fingerprint": "3d-1",
+203
View File
@@ -0,0 +1,203 @@
"""D03 수동 그리기 셈 거울(PLAN 562-1) — 파이썬 `D03_PlanAnalysis_Draw` ↔
TS `logic/D03_PlanAnalysis_Draw.ts`.
같은 높이 함수(`hill` · x > 800 = 지형 밖)로 두 쪽 값이 같은지 + 손셈 값.
./venv/Scripts/python.exe -m pytest resources/tester/test_562_1_plan_draw.py -q
"""
from __future__ import annotations
import json
import math
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from D03_PlanAnalysis import D03_PlanAnalysis_Draw as draw # noqa: E402
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
def hill(x: float, y: float) -> float:
if x > 800:
return math.nan
return 0.0005 * x * x + 3 * math.sin(y / 40)
def plane(x: float, y: float) -> float:
return 0.1 * x
LINE = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m
# (함수 이름, 인자) — 높이 자리는 "hill" · "plane" 글자로 넘김
CASES = [
("segmentLength", [[1, 2], [4, 6]]),
("groundSlopePct", [[0, 0], [100, 30], "hill"]),
("groundSlopePct", [[100, 30], [0, 0], "hill"]),
("groundSlopePct", [[700, 0], [900, 0], "hill"]),
("groundSlopePct", [[5, 5], [5, 5], "hill"]),
("fixedLengthPoint", [[10, 10], [13, 14], 20]),
("fixedLengthPoint", [[10, 10], [10, 10], 20]),
("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill"]),
("fixedSlopePoint", [[0, 0], [50, 0], -9, "hill"]),
("fixedSlopePoint", [[100, 30], [130, 70], 12, "hill"]),
("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill", 100]),
("fixedSlopePoint", [[600, 0], [700, 0], 1, "hill"]),
("compassCandidates", [[0, 0], 50, 5, "plane"]),
("compassCandidates", [[200, 10], 60, 15, "hill"]),
("compassCandidates", [[0, 0], 50, 50, "plane"]),
("compassPoint", [[0, 0], [10, 40], 50, 5, "plane"]),
("compassPoint", [[0, 0], [10, 40], 50, 50, "plane"]),
("lineLength", [LINE]),
("pointAtDistance", [LINE, -5]),
("pointAtDistance", [LINE, 75]),
("pointAtDistance", [LINE, 500]),
("distanceAlong", [LINE, [40, 70]]),
("distanceAlong", [LINE, [-10, -10]]),
("sliceLine", [LINE, 25, 160]),
("sliceLine", [LINE, 160, 25]),
("stationChainages", [210]),
("stationChainages", [40, 20]),
("stationChainages", [0]),
]
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as draw from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js";
const heights = {
hill: (x, y) => (x > 800 ? NaN : 0.0005 * x * x + 3 * Math.sin(y / 40)),
plane: (x, y) => 0.1 * x,
};
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = cases.map(([name, args]) =>
draw[name](...args.map((a) => (typeof a === "string" ? heights[a] : a))),
);
writeFileSync(process.argv[3], JSON.stringify(out));
"""
_HEIGHTS = {"hill": hill, "plane": plane}
_PY = {
"segmentLength": draw.segment_length,
"groundSlopePct": draw.ground_slope_pct,
"fixedLengthPoint": draw.fixed_length_point,
"fixedSlopePoint": draw.fixed_slope_point,
"compassCandidates": draw.compass_candidates,
"compassPoint": draw.compass_point,
"lineLength": draw.line_length,
"pointAtDistance": draw.point_at_distance,
"distanceAlong": draw.distance_along,
"sliceLine": draw.slice_line,
"stationChainages": draw.station_chainages,
}
def _py(name: str, args: list) -> object:
return _PY[name](*(_HEIGHTS[a] if isinstance(a, str) else a for a in args))
def _close(a: object, b: object) -> bool:
if a is None or b is None:
return a is b
if isinstance(a, (int, float)):
return isinstance(b, (int, float)) and abs(a - b) < 1e-6
return len(a) == len(b) and all(_close(x, y) for x, y in zip(a, b)) # type: ignore[arg-type]
@pytest.fixture(scope="module")
def ts_out(tmp_path_factory: pytest.TempPathFactory) -> list:
tmp = tmp_path_factory.mktemp("draw")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
job = tmp / "job.json"
result = tmp / "out.json"
job.write_text(json.dumps(CASES), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(job), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_ts_matches_python(ts_out: list) -> None:
for (name, args), ts in zip(CASES, ts_out):
assert _close(ts, _py(name, args)), (name, args, ts, _py(name, args))
def test_hand_values() -> None:
assert draw.segment_length((1, 2), (4, 6)) == 5
# 오르막 + · 내리막 − · 지형 밖 · 길이 0
up = draw.ground_slope_pct((0, 0), (100, 0), hill)
assert up == pytest.approx(5.0)
assert draw.ground_slope_pct((100, 0), (0, 0), hill) == pytest.approx(-5.0)
assert draw.ground_slope_pct((700, 0), (900, 0), hill) is None
assert draw.ground_slope_pct((5, 5), (5, 5), hill) is None
assert draw.fixed_length_point((10, 10), (13, 14), 20) == pytest.approx((22, 26))
# z = 0.0005 x² · x0 = 0 → 평균 기울기 0.0005 t = 9 % 첫 자리 t = 180
# 부호 무관 · 한도 100 m = 없음
assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill) == pytest.approx((180, 0), abs=1e-6)
assert draw.fixed_slope_point((0, 0), (50, 0), -9, hill) == pytest.approx((180, 0), abs=1e-6)
assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill, 100) is None
# 지형 밖에 닿음
assert draw.fixed_slope_point((600, 0), (700, 0), 1, hill) is None
# 평면 10 % · 반지름 50 · 5 % → cos θ = ±0.5 · 60 · 120 · 240 · 300°
got = draw.compass_candidates((0, 0), 50, 5, plane)
angles = sorted(round(math.degrees(math.atan2(y, x)) % 360, 6) for x, y in got)
assert angles == [60, 120, 240, 300]
assert draw.compass_candidates((0, 0), 50, 50, plane) == []
near = draw.compass_point((0, 0), (10, 40), 50, 5, plane)
assert near == pytest.approx((25, 25 * math.sqrt(3)), abs=1e-6)
assert draw.compass_point((0, 0), (10, 40), 50, 50, plane) is None
def test_line_values() -> None:
assert draw.line_length(LINE) == 210
assert draw.point_at_distance(LINE, -5) == (0, 0)
assert draw.point_at_distance(LINE, 75) == (30, 65)
assert draw.point_at_distance(LINE, 500) == (90, 180)
assert draw.distance_along(LINE, (40, 70)) == 80
assert draw.distance_along(LINE, (-10, -10)) == 0
assert draw.slice_line(LINE, 25, 160) == pytest.approx(
[(15, 20), (30, 40), (30, 100), (60, 140)]
)
assert draw.slice_line(LINE, 160, 25) == draw.slice_line(LINE, 25, 160)
assert draw.station_chainages(210) == [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 210]
assert draw.station_chainages(40, 20) == [0, 20, 40]
assert draw.station_chainages(0) == [0]
+233
View File
@@ -0,0 +1,233 @@
# -*- coding: utf-8 -*-
"""564-1 E02 [해석 시작] 체인 — 가짜 단계로 차례 · 비율 · 건너뜀 · 끊기 · 실패 격리 ·
3D 단계 = 구간 × 건기 · 우기 · 지문 같은 조각 건너뜀 · 구간 수로 나눈 진행률 ·
길 셋(지반 저장 → 시작 · 두 번째 409 · 끊기).
"""
import asyncio
import copy
import sys
from pathlib import Path
from uuid import UUID
import pytest
from fastapi import HTTPException
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from E01_StabRegister import E01_StabRegister_Source as source # noqa: E402
from E02_StabGround import E02_StabGround_Chain as chain # noqa: E402
from E02_StabGround import E02_StabGround_Model as model # noqa: E402
from E02_StabGround import E02_StabGround_Router as router # noqa: E402
from E04_Stab3D import E04_Stab3D_Router as e04 # noqa: E402
from E04_Stab3D import E04_Stab3D_Run as run3d # noqa: E402
PID = UUID("9872e9a7-e483-427b-a6b2-72cdbc063bc3")
KEYS = [k for k, _, _ in chain.STEPS]
TOTAL = sum(w for _, _, w in chain.STEPS)
def _fake(log, states=None, fail=(), interrupt=(), stop_after=None, flag=None):
"""가짜 단계 — 부른 차례 · 단계 안 반쯤 진행 · 정한 상태 · 실패 · 끊김."""
states = states or {}
def make(key):
async def step(report, stopped):
log.append(key)
report(0.5, f"{key} 반")
if key in fail:
raise RuntimeError(f"{key} 망가짐")
if key in interrupt:
raise InterruptedError
if key == stop_after:
flag[0] = True
return states.get(key, "done")
return step
return {k: make(k) for k in KEYS}
class _Job(dict):
"""update 마다 진행률을 적어 둠(뒤로 안 가는지 봄)."""
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.seen = []
def update(self, *a, **kw):
super().update(*a, **kw)
self.seen.append(self["percent"])
def _run(steps, stopped=lambda: False):
job = _Job({**chain.idle(), "running": True})
asyncio.run(chain.run_chain(job, steps, stopped))
return job, job.seen
def test_차례_비율_건너뜀():
log = []
job, seen = _run(_fake(log, states={"rock": "skip", "report": "skip"}))
assert log == KEYS
assert [s["state"] for s in job["steps"]] == ["done", "done", "skip", "done", "skip"]
assert all(s["seconds"] is not None for s in job["steps"])
assert job["percent"] == 100 and job["message"] == "끝" and job["error"] is None
assert job["stage"] is None and not job["cancelled"]
assert seen == sorted(seen) # 진행률은 뒤로 안 감
# 3D 반쯤 = 2D 몫 + 3D 몫 반(몫 = STEPS 비율)
half_3d = int(100 * (chain.STEPS[0][2] + chain.STEPS[1][2] / 2) / TOTAL)
assert half_3d in seen
def test_끊기_다음_단계_안_돎():
log, flag = [], [False]
job, _ = _run(_fake(log, stop_after="3d", flag=flag), stopped=lambda: flag[0])
assert log == ["2d", "3d"]
assert [s["state"] for s in job["steps"]] == ["done", "done", "cancel", "wait", "wait"]
assert job["cancelled"] and job["percent"] < 100
def test_단계_안에서_끊김():
log = []
job, _ = _run(_fake(log, interrupt={"3d"}))
assert log == ["2d", "3d"]
assert [s["state"] for s in job["steps"]][:3] == ["done", "cancel", "wait"]
assert job["cancelled"] and job["message"] == "끊음"
def test_실패는_그_단계만():
log = []
job, _ = _run(_fake(log, fail={"2d", "area"}))
assert log == KEYS
assert [s["state"] for s in job["steps"]] == ["error", "done", "done", "error", "done"]
assert "2D 측점 해석: 2d 망가짐" in job["error"] and "광역 위험" in job["error"]
assert job["percent"] == 100
# ---------------------------------------------------------------------------
# 3D 단계 — 구간 × 조건 · 지문 같으면 건너뜀 · 구간 수로 나눈 진행률 · E04 [끊기]
# ---------------------------------------------------------------------------
STATIONS = [{"chainage_m": float(c)} for c in range(0, 320, 20)] # 0~300 m → 구간 셋
@pytest.fixture
def fake_3d(tmp_path, monkeypatch):
calls = []
fresh = {"0.00_100.00_dry"} # 이 조각만 저장본 지문이 지금과 같음
monkeypatch.setattr(e04, "_source_root", lambda root: root)
monkeypatch.setattr(source, "stations", lambda src: STATIONS)
monkeypatch.setattr(e04, "_current_fingerprint", lambda *a: "fp")
monkeypatch.setattr(
run3d, "load", lambda root, key: {"fingerprint": "fp"} if key in fresh else None
)
def analyze(root, src, settings, finger, on_progress, should_stop):
calls.append((settings["start_m"], settings["end_m"], settings["condition"]))
assert (e04._JOBS.get(str(PID)) or {}).get("running") # E04 화면 「해석 중」
on_progress(50, "탐색")
if should_stop():
raise InterruptedError
return {"min_fs": 1.5}
monkeypatch.setattr(run3d, "analyze", analyze)
e04._JOBS.pop(str(PID), None)
return tmp_path, calls
def test_3D_구간_조건_건너뜀_진행률(fake_3d):
root, calls = fake_3d
step = chain.build_steps(PID, root)["3d"]
seen = []
state = asyncio.run(step(lambda f, m: seen.append((round(f, 4), m)), lambda: False))
sections = run3d.auto_sections(STATIONS)
assert len(sections) == 3
units = len(sections) * len(chain.CONDITIONS_3D)
assert state == "done" and len(calls) == units - 1 # 지문 같은 조각 하나 건너뜀
assert calls[0] == (0.0, 100.0, "wet") and {c for *_, c in calls} == {"dry", "wet"}
assert seen[0] == (round(1 / units, 4), f"3D 1 / {units} 구간") # 건너뛴 조각 = 바로 한 칸
assert (round(1.5 / units, 4), f"3D 2 / {units} 구간") in seen # 둘째 조각 반쯤
assert [f for f, _ in seen] == sorted(f for f, _ in seen)
assert not e04._JOBS[str(PID)]["running"]
def test_3D_E04_끊기(fake_3d):
root, calls = fake_3d
step = chain.build_steps(PID, root)["3d"]
def report(_f, _m):
e04._STOPS[str(PID)] = True # E04 화면 [끊기]
with pytest.raises(InterruptedError):
asyncio.run(step(report, lambda: False))
assert len(calls) == 1 and e04._JOBS[str(PID)]["cancelled"]
def test_3D_모두_최신이면_건너뜀(fake_3d, monkeypatch):
root, calls = fake_3d
monkeypatch.setattr(run3d, "load", lambda root, key: {"fingerprint": "fp"})
step = chain.build_steps(PID, root)["3d"]
assert asyncio.run(step(lambda f, m: None, lambda: False)) == "skip" and not calls
# ---------------------------------------------------------------------------
# 길 — [해석 시작] = 지반 저장 → 시작 · 도는 중 두 번째 409 · 끊기
# ---------------------------------------------------------------------------
def test_길_저장_시작_409_끊기(tmp_path, monkeypatch):
async def fake_project(_project_id):
return tmp_path, {"company_id": 1, "project_kind": "STABILITY"}
monkeypatch.setattr(router, "_project", fake_project)
gate = {}
def fake_steps(_pid, _root):
async def slow(report, stopped):
await gate["open"].wait()
return "done"
async def quick(report, stopped):
return "skip"
return {k: (slow if k == "2d" else quick) for k in KEYS}
monkeypatch.setattr(chain, "build_steps", fake_steps)
ground = model.build_initial(model.load_defaults(), [])
ground["materials"][0]["c"] = 7.0
async def scenario():
gate["open"] = asyncio.Event()
first = await router.run_chain(PID, router.GroundRequest(ground=copy.deepcopy(ground)))
assert first["running"] and first["ground"]["materials"][0]["c"] == 7.0
assert model.ground_path(tmp_path).is_file() # 지반 저장 먼저
await asyncio.sleep(0)
with pytest.raises(HTTPException) as busy:
await router.run_chain(PID, router.GroundRequest(ground=copy.deepcopy(ground)))
assert busy.value.status_code == 409
assert (await router.run_progress(PID))["stage"] == "2d"
assert (await router.run_cancel(PID))["stopping"] is True
gate["open"].set()
for _ in range(50):
await asyncio.sleep(0)
return await router.run_progress(PID)
done = asyncio.run(scenario())
assert not done["running"] and done["cancelled"]
assert [s["state"] for s in done["steps"]][:2] == ["done", "cancel"]
def test_틀린_지반은_시작_안_함(tmp_path, monkeypatch):
async def fake_project(_project_id):
return tmp_path, {"company_id": 1, "project_kind": "STABILITY"}
monkeypatch.setattr(router, "_project", fake_project)
chain._JOBS.pop(str(PID), None)
ground = model.build_initial(model.load_defaults(), [])
ground["materials"][0]["gamma_t"] = 9.9
with pytest.raises(HTTPException) as bad:
asyncio.run(router.run_chain(PID, router.GroundRequest(ground=ground)))
assert bad.value.status_code == 422 and not chain._JOBS.get(str(PID))
+33 -1
View File
@@ -114,7 +114,10 @@ export const ui_locales_e = {
E02_Col_Points: ["점 (offset · 깊이)", "Points (offset · depth)"],
/* --- 상태 · 알림 --- */
E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."],
E02_Unsaved: [
"해석 전 — 바뀐 값은 [해석 시작] 때 저장됩니다.",
"Not analyzed — changes are saved on [Run analysis].",
],
E02_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."],
E02_Saved: ["저장했습니다.", "Saved."],
E02_ResetDone: ["초기값으로 되돌렸습니다.", "Restored to initial values."],
@@ -127,6 +130,35 @@ export const ui_locales_e = {
E02_UserLayer: ["사용자 지층 — 출처 없음", "User layer — no source"],
E02_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."],
/* --- [해석 시작] 체인(PLAN 564-3) --- */
E02_Run: ["해석 시작", "Run analysis"],
E02_Run_Hint: [
"[해석 시작] = 지반 값 저장 → 2D · 3D(모든 구간 · 건기 · 우기) · 암반 · 광역 · 보고서를 차례로 셈. 입력이 같은 단계는 건너뜀 · 1.2 km 약 25~30 분 · 다른 페이지로 가도 계속 돎.",
"[Run analysis] = save ground values → 2D · 3D (all sections · dry · wet) · rock · area · report in turn. Unchanged steps are skipped · about 25–30 min per 1.2 km · keeps running on other pages.",
],
E02_Run_Running: ["해석 중", "Running"],
E02_Run_Cancel: ["끊기", "Stop"],
E02_Run_Stopping: [
"끊는 중 — 지금 조각이 끝나면 멈춥니다.",
"Stopping — halts after the current piece.",
],
E02_Run_Done: ["해석을 마쳤습니다.", "Analysis finished."],
E02_Run_Cancelled: [
"해석을 끊었습니다 — 끝난 단계는 저장됨.",
"Analysis stopped — finished steps are saved.",
],
E02_Run_Step_2d: ["2D 측점 해석", "2D station analysis"],
E02_Run_Step_3d: ["3D 구간 해석", "3D section analysis"],
E02_Run_Step_rock: ["암반 해석", "Rock analysis"],
E02_Run_Step_area: ["광역 위험", "Area hazard"],
E02_Run_Step_report: ["보고서", "Report"],
E02_Run_State_wait: ["기다림", "Waiting"],
E02_Run_State_run: ["도는 중", "Running"],
E02_Run_State_done: ["끝남", "Done"],
E02_Run_State_skip: ["최신 · 건너뜀", "Up to date · skipped"],
E02_Run_State_error: ["실패", "Failed"],
E02_Run_State_cancel: ["끊음", "Stopped"],
/* --- 입력 검사(코드 = 파이썬 check_ground 와 같은 낱말) --- */
E02_Err_number: ["숫자를 넣으세요", "Enter a number"],
E02_Err_range: ["범위 밖", "Out of range"],
-3
View File
@@ -25,9 +25,6 @@ export const ui_locales_e03 = {
E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."],
E03_View3d: ["3D 보기", "3D View"],
E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"],
E03_RepSave: ["대표 단면 저장", "Save Representative"],
E03_RepSaved: ["대표 단면을 저장했습니다.", "Representative sections saved."],
E03_RepUnsaved: ["대표 단면 바뀜 — 저장 안 됨", "Representative changes not saved"],
E03_Loading: ["불러오는 중…", "Loading…"],
E03_NotYet: [
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
+2 -1
View File
@@ -39,7 +39,6 @@ export const ui_locales_e04 = {
E04_Condition_wet: ["우기 (지표까지 포화)", "Wet (saturated to surface)"],
E04_Defaults: ["기본값", "Defaults"],
E04_Analyze: ["해석", "Analyze"],
E04_Reanalyze: ["다시 해석", "Re-analyze"],
E04_Cancel: ["끊기", "Stop"],
E04_Running: ["해석 중", "Analyzing"],
E04_Cancelled: ["해석을 끊었습니다.", "Analysis stopped."],
@@ -48,6 +47,8 @@ export const ui_locales_e04 = {
E04_ReadOnly: ["보기만 허용된 프로젝트입니다.", "View-only project."],
E04_Saved: ["결과 있음", "Saved"],
E04_Stale: ["옛 결과", "Outdated"],
E04_Short_dry: ["건기", "Dry"],
E04_Short_wet: ["우기", "Wet"],
/* --- 가운데 지도 · 3D --- */
E04_View_Plan: ["평면 지도", "Plan map"],