Merge remote-tracking branch 'origin/dev' into main_desktop_1
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -133,6 +134,31 @@ def load_terrain_and_streams(
|
||||
return Terrain(np.asarray(x), np.asarray(y), np.asarray(z), np.asarray(valid)), streams
|
||||
|
||||
|
||||
def _choice_path(project_root: Path) -> Path:
|
||||
return project_root / "B05_Profile" / "route" / "initial_choice.json"
|
||||
|
||||
|
||||
def read_initial_choice(project_root: Path) -> dict[str, Any]:
|
||||
"""현재 초기값(갈래 · 비중) — [확인] 때 적어 둔 것. 없거나 못 읽으면 추종."""
|
||||
try:
|
||||
data = json.loads(_choice_path(project_root).read_text(encoding="utf-8"))
|
||||
logic = data["logic"] if data.get("logic") in MODES else "follow"
|
||||
return {"logic": logic, "weights": resolve_weights(logic, data.get("weights"))}
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return {"logic": "follow", "weights": resolve_weights("follow", None)}
|
||||
|
||||
|
||||
def write_initial_choice(
|
||||
project_root: Path, logic: str, weights: dict[str, float] | None
|
||||
) -> dict[str, Any]:
|
||||
"""[확인] 때만 부름 — 작업본 한 칸. 다시 열기 · 다른 PC 에서도 같은 초기값."""
|
||||
choice = {"logic": logic, "weights": resolve_weights(logic, weights)}
|
||||
path = _choice_path(project_root)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(choice, ensure_ascii=False), encoding="utf-8")
|
||||
return choice
|
||||
|
||||
|
||||
def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]:
|
||||
"""갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값."""
|
||||
if mode != "weighted":
|
||||
@@ -192,6 +218,7 @@ def generate_initial_route(
|
||||
)
|
||||
return {
|
||||
"mode": mode,
|
||||
"logic": mode,
|
||||
"weights": used,
|
||||
"planned": [[round(x, 4), round(y, 4)] for x, y in outline.vertices],
|
||||
"nodes": [node.as_dict() for node in outline.nodes],
|
||||
|
||||
@@ -41,9 +41,10 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import AliasChoices, BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
|
||||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||||
from common_util.common_util_route_geometry import (
|
||||
densify_route,
|
||||
@@ -96,7 +97,9 @@ class RouteInitialWeights(BaseModel):
|
||||
class RouteInitialRequest(BaseModel):
|
||||
"""초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted."""
|
||||
|
||||
mode: Literal["follow", "earthwork", "gentle", "weighted"] = "follow"
|
||||
mode: Literal["follow", "earthwork", "gentle", "weighted"] = Field(
|
||||
"follow", validation_alias=AliasChoices("logic", "mode")
|
||||
)
|
||||
weights: RouteInitialWeights | None = None
|
||||
|
||||
|
||||
@@ -104,6 +107,8 @@ class RouteReplanRequest(BaseModel):
|
||||
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
|
||||
|
||||
vertices: list[RouteVertexInput] = Field(default_factory=list)
|
||||
initial: RouteInitialRequest | None = None
|
||||
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
|
||||
|
||||
|
||||
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
|
||||
@@ -496,6 +501,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
|
||||
"violation_count": outline.violation_count,
|
||||
"edited": bool(working),
|
||||
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
|
||||
}
|
||||
|
||||
|
||||
@@ -561,7 +567,23 @@ async def replan_route(
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "error", "message": result["error"]}
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **result}
|
||||
choice = request.initial
|
||||
current = (
|
||||
await asyncio.to_thread(
|
||||
write_initial_choice,
|
||||
project_root,
|
||||
choice.mode,
|
||||
choice.weights.model_dump() if choice.weights else None,
|
||||
)
|
||||
if choice
|
||||
else await asyncio.to_thread(read_initial_choice, project_root)
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
**result,
|
||||
"current_initial": current,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/replan/reset", response_model=None)
|
||||
@@ -656,4 +678,10 @@ async def initial_route(
|
||||
result["metrics"]["length_m"],
|
||||
result["metrics"]["offset_avg_m"],
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **result}
|
||||
current = await asyncio.to_thread(read_initial_choice, project_root)
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
**result,
|
||||
"current_initial": current,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 저장 왕복 시험용 — 화면 TS 를 node 로 묶어 저장 본문을 만든다.
|
||||
// node helper_b05_save_payload.mjs <저장소 뿌리> · 표준입력 = 작업 목록(JSON) · 표준출력 끝 줄 = 결과 목록
|
||||
// {op:"attach", entries:[관 응답], points:[{chainage_m, source}]} → attach 결과(DetailPipeInput[])
|
||||
// {op:"attach", entries:[관 응답], moves:[[옛, 새]], points:[{chainage_m, source}]} → attach 결과
|
||||
// {op:"station", chainage, interval} → 구조물 측점 칸에 쓰고 다시 읽은 값
|
||||
// {op:"culvert", server:[관 응답], pending:{측점:조각}, moves:{측점:새 측점}} → PUT 본문 points 또는 null
|
||||
import fs from "node:fs";
|
||||
@@ -78,6 +78,8 @@ try {
|
||||
if (job.op === "attach") {
|
||||
const store = mod.createFacilityStore();
|
||||
store.replaceFromResponse(job.entries);
|
||||
// 지도에서 끈 관 — 화면이 끌기를 마치면 속성을 새 측점으로 옮김(onMove)
|
||||
for (const [from, to] of job.moves ?? []) store.move(from, to);
|
||||
results.push(store.attach(job.points));
|
||||
} else if (job.op === "station") {
|
||||
const fields = mod.stationFields("측점", () => job.interval);
|
||||
|
||||
@@ -72,6 +72,10 @@ out.shown = [fields.station.value, fields.remainder.value];
|
||||
out.untouched = fields.read(INTERVAL, true);
|
||||
fields.write(123.3721, INTERVAL);
|
||||
out.untouchedRaw = fields.read(INTERVAL, true);
|
||||
// 사용자가 놓은 119.8 — 다시 열면 5 + 19.8 로 보이고 그대로 읽힘
|
||||
fields.write(119.8, INTERVAL);
|
||||
out.r119 = [fields.station.value, fields.remainder.value, fields.read(INTERVAL, true)];
|
||||
fields.write(123.3721, INTERVAL);
|
||||
// 사용자가 잔여를 고치면 친 값으로
|
||||
fields.remainder.value = "5.2";
|
||||
fields.remainder.fire("change");
|
||||
|
||||
@@ -301,7 +301,8 @@ class Flow:
|
||||
}
|
||||
for p in self.want_pipes
|
||||
]
|
||||
(attached,) = _screen([{"op": "attach", "entries": entries, "points": points}])
|
||||
job = {"op": "attach", "entries": entries, "moves": [self.pipe_move], "points": points}
|
||||
(attached,) = _screen([job])
|
||||
return attached
|
||||
|
||||
def b06_pipe_payload(self) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -34,6 +34,7 @@ def test_칸을_안_고치면_실어_둔_측점_그대로(results):
|
||||
assert results["untouched"] == 123.37
|
||||
assert results["untouchedRaw"] == 123.3721
|
||||
assert results["retypedSame"] == 123.37
|
||||
assert results["r119"] == ["5", "19.8", 119.8]
|
||||
|
||||
|
||||
def test_칸을_고치면_친_값(results):
|
||||
@@ -49,3 +50,54 @@ def test_계획고_편집은_가까운_새_측점으로_옮김(results):
|
||||
}
|
||||
# 옮길 곳이 없으면 종전대로 지움
|
||||
assert "200.000" not in results["deleted"]["station_offsets"]
|
||||
|
||||
|
||||
# ── 서버 — 사용자가 놓은 구조물 측점은 소수 그대로(격자 붙이기는 횡단 측점에만) ─────────
|
||||
# 브레인 판정(다): 횡단 측점 · 파일 이름의 정수 m 격자 붙이기는 그대로 두고, 구조물 정본 측점만
|
||||
# 사용자가 놓은 소수 그대로 저장 · 다시 읽기. 임시 storage 에서만 씀(공유 storage 안 건드림).
|
||||
|
||||
|
||||
def _flat_sampler():
|
||||
import numpy as np
|
||||
|
||||
class _Flat:
|
||||
def sample_xy(self, xy):
|
||||
count = len(np.asarray(xy, dtype=np.float64))
|
||||
return np.full(count, 100.0), np.ones(count, dtype=bool)
|
||||
|
||||
return _Flat()
|
||||
|
||||
|
||||
def test_사용자_구조물_119_8_저장_다시_읽기_그대로(tmp_path):
|
||||
from B05_Profile.B05_Profile_Engine_Sections import cross_filename, resolve_extra_stations
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
|
||||
from B05_Profile.B05_Profile_Structures_Schema import StructureInstance
|
||||
|
||||
item = StructureInstance(
|
||||
type_id="cross_drain_exposed",
|
||||
placement="point",
|
||||
chainage_m=119.8,
|
||||
memo="메모만",
|
||||
placement_source="manual",
|
||||
)
|
||||
save_structures(str(tmp_path), [item], base_revision=0)
|
||||
_, loaded = load_structures(str(tmp_path))
|
||||
assert [s.chainage_m for s in loaded] == [119.8]
|
||||
|
||||
extras = resolve_extra_stations(tmp_path, [])
|
||||
assert [chainage for chainage, _ in extras] == [119.8]
|
||||
sections = generate_sections(
|
||||
[[0.0, 0.0, 100.0], [300.0, 0.0, 100.0]],
|
||||
_flat_sampler(),
|
||||
SectionGenerationOptions(extra_stations=extras),
|
||||
)
|
||||
chainages = [round(s["chainage_m"], 3) for s in sections["longitudinal"]["stations"]]
|
||||
# 횡단 측점은 격자 120 으로 붙음(파일 이름 cross_00120m 한 장) — 정본 측점은 119.8 그대로
|
||||
assert 120.0 in chainages and 119.8 not in chainages
|
||||
assert cross_filename(119.8) == cross_filename(120.0)
|
||||
_, again = load_structures(str(tmp_path))
|
||||
assert [s.chainage_m for s in again] == [119.8] and again[0].memo == "메모만"
|
||||
|
||||
@@ -13,8 +13,10 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||
Terrain,
|
||||
generate_initial_route,
|
||||
grade_line,
|
||||
read_initial_choice,
|
||||
resolve_weights,
|
||||
stream_lines,
|
||||
write_initial_choice,
|
||||
)
|
||||
|
||||
GRADE = 0.14
|
||||
@@ -124,3 +126,24 @@ def test_weights_and_stream_filter() -> None:
|
||||
def test_unknown_mode_rejected(hill: Terrain) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_run(hill, "shortest")
|
||||
|
||||
|
||||
def test_initial_choice_roundtrip(tmp_path) -> None:
|
||||
"""[확인] 때 적은 현재 초기값을 다시 읽음 · 없거나 깨지면 추종."""
|
||||
assert read_initial_choice(tmp_path)["logic"] == "follow"
|
||||
write_initial_choice(tmp_path, "weighted", {"expected": 60, "earthwork": 20, "grade": 20})
|
||||
assert read_initial_choice(tmp_path) == {
|
||||
"logic": "weighted",
|
||||
"weights": {"expected": 60.0, "earthwork": 20.0, "grade": 20.0},
|
||||
}
|
||||
(tmp_path / "B05_Profile" / "route" / "initial_choice.json").write_text("{", encoding="utf-8")
|
||||
assert read_initial_choice(tmp_path)["logic"] == "follow"
|
||||
|
||||
|
||||
def test_request_accepts_logic_name() -> None:
|
||||
from B05_Profile.B05_Profile_Router_Replan import RouteInitialRequest, RouteReplanRequest
|
||||
|
||||
assert RouteInitialRequest.model_validate({"logic": "gentle"}).mode == "gentle"
|
||||
assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork"
|
||||
replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}})
|
||||
assert replan.initial is not None and replan.initial.mode == "weighted"
|
||||
|
||||
Reference in New Issue
Block a user