Files
Aislo/common_util/common_util_project_settings.py
T
eomsangdonandClaude Opus 5 8f4ec48759 feat(b06·b08): ㉱ (나) 암 환산계수를 구성비 가중으로 — 토적표·유토곡선·운반표가 한 함수(mixed_conversion_factors)
- B06 암은 한 종류 자리표시라 토적표 보정량·유토곡선이 리핑암 C 하나로 쌓았음 · 구성비·갈래별 시공법이 다 서면 암 두 칸을 Σ몫×C(시공법)로 · 비면 종전 값(인계가 막힘 사유)
- 계수를 내는 곳 넷(B06 서버 재계산·곡선 문맥 · B08 토적표·사토 계수)이 같은 함수 · 화면 「무엇을 골랐나」는 갈래별 값 그대로
- 936be972 연암 60 리핑/보통암 40 발파 · 서버 재계산: 토적표 깎기 보정량 6,296.81 = 유토곡선 6,296.8 · 암 보정량 4,167.31 → 4,384.74 · 토취 7,259.84 → 7,053.83 · 사토 55.31 → 58.2 · 운반 검산 0 · 되돌려 재계산 원래 값 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 21:11:44 +09:00

423 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""프로젝트 설정 — 수량(B08)·원가(B09) 두 페이지가 함께 읽는 값 (PLAN 8-7).
자리
`<project_root>/project_settings.json` — 루트, `project_manifest.json` 옆.
매니페스트의 `stages` 는 **단계 산출물** 목록이고, 설정은 산출물이 아니라 **프로젝트 값**이다.
단계 폴더에 넣으면 주인이 애매해진다.
구획 — 페이지마다 자기 것만 쓴다
`quantity` = B08 · `estimation` = B09. 남의 구획은 **읽기만** 한다.
`dataset_versions` 도 구획마다 따로 둔다 — 한 칸을 둘이 쓰면 저장할 때마다 서로 지운다.
⚠ **경계를 코드로 막는다** — `save_section()` 은 이름 붙은 한 구획만 갈아 끼우고 나머지는
원본 그대로 둔다. 통째로 덮는 길을 두지 않는 까닭은, 두 페이지가 같은 파일을 쓰기 때문이다
(오늘 `main.py` 에서 같은 모양의 사고를 이미 겪었다).
⚠ `dataset_versions` 는 **기록**이지 정본이 아니다
여기 적히는 것은 「저장 시점에 무엇을 고른 상태였나」이고, 계산을 되살릴 때 쓰는 정본은
**프로젝트 스냅샷**이다. 둘이 어긋나면 **스냅샷이 이긴다.**
⚠ `*_override` 는 기본이 `None` 이다
「프로젝트가 안 정했으면 `config` 정본을 쓴다」는 뜻이다. 기본값을 복사해 넣으면 나중에
정본이 바뀌어도 옛 프로젝트가 안 따라온다. 값을 넣는 것은 **설계자가 일부러 바꿨을 때만**이다.
⚠ 반영률 기본은 100 이다 (PLAN 8-11 · 8-10 ★법대로)
실무 관측 80/50/80 은 설계자가 비고란에 손으로 적은 값이지 법정값이 아니다. 기본값으로
넣지 않는다.
작업본 3층 (CLAUDE.md 5장)
조작은 캐시(sessionStorage)에 쌓이고 [저장]·[확정]에서 이 파일로 간다. 자동저장은 만들지 않는다.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Iterable
from common_util.common_util_json import atomic_write_json
from config.config_system_design import (
EARTHWORK_CONVERSION_C_RANGES,
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
EARTHWORK_HAUL_LIMIT_BASIS,
)
SETTINGS_FILENAME = "project_settings.json"
SCHEMA_VERSION = 1
# 반영률 키 — 사면 계열과 짝이다. 값은 퍼센트이고 기본은 전부 100.
APPLICATION_RATIO_KEYS = (
"fill_slope_compaction", # 성토면다짐
"seed_spray_fill", # 초류종자살포(성토면)
"seed_spray_cut", # 초류종자살포(절토면)
"obstacle_removal", # 지장목제거
)
# 암 갈래 세트 — **개수를 코드에 박지 않는다**(PLAN 8-13).
# 울진 2 · 거창 5 · 오솔길 BOM 1 로 공사마다 다르다. 프로젝트가 하나를 고른다.
ROCK_CLASS_SETS: dict[str, tuple[str, ...]] = {
"single": ("토사", "암"),
"uljin2": ("토사", "연암", "발파암"),
"geochang5": ("토사", "풍화암", "연암", "보통암", "경암"),
}
DEFAULT_ROCK_CLASS_SET = "geochang5"
# 암 시공법 — 품셈이 공종을 가르는 기준. `None` 은 「아직 안 정함」이고 기본값이다.
ROCK_METHOD_RIPPING = "ripping" # 긁어내기 — 암절취
ROCK_METHOD_BLASTING = "blasting" # 터뜨리기 — 발파암
ROCK_METHODS = (ROCK_METHOD_RIPPING, ROCK_METHOD_BLASTING)
def rock_method(settings: dict[str, Any], rock_class: str) -> str | None:
"""갈래 하나의 시공법. 안 정했으면 `None` — **기본값으로 때우지 않는다.**"""
value = (settings.get("rock_methods") or {}).get(rock_class)
return value if value in ROCK_METHODS else None
def default_settings() -> dict[str, Any]:
"""빈 설정. `estimation` 은 **자리만** 만든다 — 채우는 것은 B09 몫이다."""
return {
"schema_version": SCHEMA_VERSION,
"quantity": {
"rock_class_set": DEFAULT_ROCK_CLASS_SET,
"rock_classes": list(ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET]),
# 갈래별 비율(%). 설계자가 넣는 값이라 기본은 비워 둔다 —
# 측점별 암질 판정에 기대지 않는다는 것이 8-1 사용자 확정이다.
"rock_ratios_pct": {},
# 갈래별 **시공법** — `{갈래이름: "ripping"|"blasting"}`.
# ⚠ 갈래 이름(연암·보통암…)만으로는 **긁어내는 암인지 터뜨리는 암인지** 알 수 없고,
# 품셈은 그 둘을 다른 공종으로 둔다(암절취 FP-09-04 / 발파암 FP-09-05).
# 기본은 **비워 둔다** — 찍으면 공종이 조용히 틀린다. 안 정하면 인계에서
# 「시공법 미지정」으로 드러난다(2026-09-07 일감 9 에서 드러난 자리).
"rock_methods": {},
"conversion_factors_override": None,
# 도쟈 한계거리(m) — `None` 은 기본값(config 60 m, 근거 셋 일치)이 선다.
# 설계 조건이라 바꿀 수 있다(2026-09-13 판정). 종무대 20 m 는 규정이라 칸이 없다.
# ⚠ 종전 `haul_limits_m_override` 는 아무도 안 읽던 칸이라 이것으로 갈음했다.
"dozer_haul_limit_m": None,
"application_ratios_pct": {key: 100 for key in APPLICATION_RATIO_KEYS},
# 자재총괄의 관급/사급 구분 — `{자재명: "owner_supplied"|"contractor_supplied"}`
# 또는 `{자재명: {"supply": …, "install_by": "contractor"|"owner"}}`.
# ⚠ **법이 아니라 발주 결정**이라 기본은 비워 둔다. 안 정한 자재는 「미분류」로
# 화면에 드러난다 — 사급으로 조용히 넘기면 관급자재대가 새 나간다.
# 표토제거 두께(m) — ⚠ **품셈이 정하는 값이 아니다.** 9-15 [주]② 가
# 「T : 표토두께(m)」로 **공식의 입력 변수**로 두었다(2026-09-07 원문 확인).
# 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음).
"topsoil_thickness_m": None,
"material_supply": {},
# 콘크리트 타설 방식 — `ready_mixed`(FP-12-01-01) / `machine_mixed`(-02) /
# `hand_mixed`(-03). **설계 판단**이라 사용자가 고른다.
# ⚠ 기본은 `None` — 「안 정함」과 「일부러 레디믹스트를 고른 것」을 갈라야
# 화면이 「기본값 적용 중」을 정직하게 띄운다. 값을 미리 넣으면 그 구별이 사라진다.
"concrete_placing_method": None,
# 부대시설 개소 — `{항목키: 개소}` (2026-09-09 사용자 확정 ⑬).
# ⚠ **산식으로 만들지 않는다.** 국가지점번호판은 임도규정 제26조제5항이
# 「500미터 마다 설치·관리하되 **필요시 거리를 조정**」이라 하고, 기점 포함·
# 종점 잔여·갈림길 중복을 원문이 안 정한다(지식DB 부대시설 [구현]).
# ⇒ `ceil(연장÷500)` 을 확정 산식으로 쓰지 않고 **개소를 받는다.**
# 비워 두면 물량을 안 낸다(0 으로 때우지 않음).
"ancillary_counts": {},
# 층따기 길이(깊이, m) — ⭐ 2026-09-09 사용자 확정 2차 ①.
# ⚠ **면적이 정본**이고 부피는 **면적 × 이 길이**로 낸다. 그러면 품셈 ㎥ 단가를
# 그대로 쓸 수 있다(단위 불일치가 풀림).
# ⚠ 기본값을 두지 않는다 — 안 넣으면 물량을 안 낸다(0 으로 때우지 않음).
# 교본이 「층따기 높이·폭은 **설계도서에 명시**」라 해 설계 입력이다.
"bench_cut_depth_m": None,
# 사토장까지 운반거리(m) — 설계 입력이다. 품셈은 거리를 정하지 않는다.
# ⚠ `None` 은 「안 정함」이고, 그러면 사토 운반 줄이 **막힌 채로** 선다.
# (2026-09-09 확정 3차 ③ — 권고할 값이 없어 **물음으로 남긴 자리**다.)
"spoil_site_distance_m": None,
# 기초잡석 두께(m) — 2026-09-09 사용자 확정 3차 ② 「0.2 m」.
# ⚠ 품셈 12-25 는 ㎥당 품만 주고 두께를 정하지 않는다. 폭은 버림 폭과 같고
# (KCS 34 50 05) 두께가 이 값이다. 화면에서 바꿀 수 있다.
"rubble_base_thickness_m": 0.2,
# 구조물터파기 용수 유무 — 품셈 9-13 의 18구분 중 한 축.
# ⚠ **사용자 확정이 아니라 통상값**이다(확정 3차 ④). 화면·사유에 그 사실을
# 적어 두고 사용자가 뒤집을 수 있게 둔다.
"structure_trench_water": "육상",
# 표토 운반거리(m) — ⚠ 별표2 Ⅰ.2.차.(6) 「표토는 전량 제거한 후 … 최고 홍수위보다
# 높은 장소로 **운반하고 쌓아두어야** 한다」. 제거만 세면 법이 요구하는 운반이 빠진다.
# 거리는 현장값이라 품셈이 정하지 않는다 — 비면 운반 줄이 막힌 채로 선다.
"topsoil_haul_distance_m": None,
# 표토제거 대상 — `None` 은 기본 「노면 + 절토」(별표2 문언) · `"road_only"` 는 노면만.
# ⚠ 노면만은 실무 영월 한 건(1m당 3.34㎡) 관측이라 일반화하지 않고 설계자가 고름
# (2026-09-14 브레인 판정 — 3배 차이라 칸으로 둠).
"topsoil_target": None,
# 임목축적 등급 — 품셈 9-21 제근이 **소·중·밀** 셋으로 갈리는 축.
# [주]① 「소림 30~60㎥/㏊ · 중림 60~90 · 밀림 90 이상」 — 본수가 아니라 **축적**이다.
# ⚠ 산림조사부·영림계획에서 옮겨 적는 값이라 프로그램이 만들 수 없다. 비면 막는다.
"stand_volume_class": None,
# 규준틀 개소당 재료 — `{자재명: 개소당 수량}`. **비우면 제안값(실무 관측)이 선다.**
# 품셈 11-2·11-3 [주]④ 가 「재료량은 설계수량에 따른다」라 값을 안 주는 자리이고,
# 세는 것 자체는 확정이라 **제안값 + 고칠 수 있게**로 둔다(확정 ⑨·⑩ 과 같은 틀).
"frame_material": {},
# 임목파쇄 — **기본 꺼짐**(확정 5차 5번). 현장에 따라 켤 수 있는 칸.
# ⚠ 근주이식은 칸도 두지 않는다 — 「셀지 말지가 설계 판단」이라 켤 자리가 없으면
# 물을 일도 없다. 켜도 **부피는 지어내지 않는다**(아래 칸이 비면 줄만 서고 사유).
"wood_chipping_enabled": False,
"wood_chipping_volume_m3": None,
# 노체다짐(9-16-2) — **기본 꺼짐**(2026-09-13 판정). [주]⑤ 「대규모 성토지로서
# 층다짐이 필요한 경우 적용」 조건부라 늘 서지 않는다. 켜면 토공집계에 별도 줄.
"subgrade_compaction_enabled": False,
# 임목폐기물 — 현장 조사값 넷 `{stems_per_1000m2, dbh_cm, height_m, unit_weight_kg_m3}`.
# ⚠ 기본값을 두지 않는다 — 비면 톤이 안 선다(수종·임분마다 달라 지어내지 않음).
"tree_waste": {},
# 뿌리 산정법 — `None` 은 분배비 15/85(기본) · `"root_ball"` 은 뿌리분 체적 × 1,300㎏/㎥
# (환경친화적인 도로건설 지침 부록1). 뿌리분 직경은 `tree_waste.root_ball_diameter_m`.
"tree_waste_root_method": None,
# 처리단가(원/톤) — KCRA 중간처리단가 미확보라 **수동**. 넣으면 금액이 서되 미확정.
"tree_waste_unit_price_krw_per_ton": None,
# 폐기물 분리발주 — 기본 아님(경비로 원가에 듦 · 2026-09-14 판정).
"waste_separate_order": False,
"dataset_versions": {},
},
"estimation": {
# ⚠ 「연도」가 아니라 **판**을 가리킨다 — 조달청 제비율은 연중에도 개정된다
# (현행판 2026-04-13). 「2026년」만으로는 어느 판인지 안 정해진다.
# 값은 `dataset_id` + `effective_date` + `sha256` 세 쪽.
"rate_dataset": None,
"price_slot_names": {},
"dataset_versions": {},
},
}
def settings_path(project_root: str | Path) -> Path:
return Path(project_root) / SETTINGS_FILENAME
def load_settings(project_root: str | Path) -> dict[str, Any]:
"""설정을 읽는다. 파일이 없거나 깨졌으면 기본값을 돌려준다(예외를 올리지 않는다).
읽기가 실패해도 화면은 서야 한다 — 설정은 계산을 **거드는** 값이지 없으면 못 도는 값이 아니다.
"""
path = settings_path(project_root)
if not path.exists():
return default_settings()
try:
stored = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return default_settings()
if not isinstance(stored, dict):
return default_settings()
return _merge(default_settings(), stored)
def _merge(base: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]:
"""저장분을 기본값 위에 얹는다. **새로 생긴 키가 빠지지 않게** 한 겹만 재귀한다."""
merged = dict(base)
for key, value in stored.items():
current = merged.get(key)
if isinstance(current, dict) and isinstance(value, dict):
merged[key] = _merge(current, value)
else:
merged[key] = value
return merged
SECTIONS = ("quantity", "estimation")
def save_section(
project_root: str | Path,
section: str,
values: dict[str, Any],
*,
replace_keys: Iterable[str] = (),
) -> dict[str, Any]:
"""한 구획만 갈아 끼운다 — 남의 구획은 **손대지 않는다**.
두 페이지가 같은 파일을 쓰므로 통째로 덮으면 상대 값이 사라진다. 그래서 **통째로 쓰는
함수를 두지 않는다** — 쓰려면 반드시 구획 이름을 대야 한다.
⚠ `replace_keys` — **지울 수 있어야 하는 칸**은 병합이 아니라 통째로 갈아 끼운다.
「고른 값을 안 정함으로 되돌리기」가 병합으로는 안 되기 때문이다(2026-09-07 화면에서
걸린 자리 — 시공법을 한 번 고르면 되돌릴 길이 없었다).
"""
if section not in SECTIONS:
raise ValueError(f"모르는 구획: {section} (쓸 수 있는 것: {', '.join(SECTIONS)})")
settings = load_settings(project_root)
merged = _merge(settings.get(section) or {}, values)
for key in replace_keys:
if key in values:
merged[key] = values[key]
settings[section] = merged
settings["schema_version"] = SCHEMA_VERSION
atomic_write_json(settings_path(project_root), settings)
return settings
def quantity_settings(project_root: str | Path) -> dict[str, Any]:
"""B08 구획만 꺼낸다."""
return load_settings(project_root).get("quantity") or {}
def estimation_settings(project_root: str | Path) -> dict[str, Any]:
"""B09 구획만 꺼낸다 — 범위 계수·장비 규격처럼 **사용자가 고른 단가 조건**이 여기 산다."""
return load_settings(project_root).get("estimation") or {}
def rock_classes(settings: dict[str, Any]) -> list[str]:
"""이 프로젝트의 암 갈래 목록. 세트 이름이 낯설면 저장된 목록을 그대로 쓴다."""
stored = settings.get("rock_classes")
if isinstance(stored, list) and stored:
return [str(item) for item in stored]
name = str(settings.get("rock_class_set") or DEFAULT_ROCK_CLASS_SET)
return list(ROCK_CLASS_SETS.get(name, ROCK_CLASS_SETS[DEFAULT_ROCK_CLASS_SET]))
#: 타설 방식 — 안 정했을 때 쓰는 값. **금액에 바로 걸리는 자리**라 화면이 잠정임을 띄운다.
CONCRETE_PLACING_METHODS = ("ready_mixed", "machine_mixed", "hand_mixed")
DEFAULT_CONCRETE_PLACING_METHOD = "ready_mixed"
def concrete_placing_method(settings: dict[str, Any]) -> tuple[str, bool]:
"""(타설 방식, 기본값을 쓰고 있는가).
⚠ 둘째 값이 참이면 **사용자가 아직 안 정한 것**이다 — 화면이 「기본값 적용 중」을 띄워야
한다. 조용히 기본으로 돌면 사용자는 그것이 잠정인 줄도 모른다.
"""
stored = settings.get("concrete_placing_method")
if stored in CONCRETE_PLACING_METHODS:
return str(stored), False
return DEFAULT_CONCRETE_PLACING_METHOD, True
#: 표토제거 대상 — 기본은 법 문언(별표2 Ⅰ.2.차.(6) 「노면·절토대상지」).
TOPSOIL_TARGETS = ("road_and_cut", "road_only")
DEFAULT_TOPSOIL_TARGET = "road_and_cut"
def topsoil_target(settings: dict[str, Any]) -> str:
"""표토제거 대상. 모르는 값·안 정함은 기본(노면 + 절토)."""
stored = settings.get("topsoil_target")
return str(stored) if stored in TOPSOIL_TARGETS else DEFAULT_TOPSOIL_TARGET
def earthwork_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]:
"""이 프로젝트가 쓸 토량환산계수 — **기본값 위에 고른 값만 얹는다.**
⚠ 정의처는 여전히 `config_system_design.EARTHWORK_CONVERSION_FACTORS` 한 곳이다.
여기서 값을 새로 적지 않고, 설계자가 고른 갈래만 갈아 끼운다. 안 고른 갈래는
키 자체가 없어 정본이 그대로 선다 — 기본값을 복사해 넣지 않는 까닭은 이 파일
머리글 `*_override` 규칙과 같다.
⚠ 이 값은 토적표만 쓰는 것이 아니다 — 유토곡선(B06)·운반표·기초단가가 같이 읽는다.
그래서 읽는 자리마다 상수를 직접 들지 말고 **이 함수를 거친다.**
고른 값의 모양 — `conversion_factors_override`
`{"ripping_rock": {"compacted": 1.0, "reason": "토질시험 값"}}`
`reason` 은 품셈 범위 밖을 골랐을 때 남기는 사유이고 계산에 안 쓴다.
"""
resolved = {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
override = settings.get("conversion_factors_override")
if not isinstance(override, dict):
return resolved
for kind, entry in override.items():
if kind not in resolved or not isinstance(entry, dict):
continue
value = entry.get("compacted")
if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value) > 0:
resolved[kind]["compacted"] = float(value)
return resolved
#: 암 시공법 → 환산계수 갈래 이름(`EARTHWORK_CONVERSION_FACTORS` 키).
ROCK_METHOD_KINDS = {ROCK_METHOD_RIPPING: "ripping_rock", ROCK_METHOD_BLASTING: "blasting_rock"}
def mixed_conversion_factors(settings: dict[str, Any]) -> dict[str, dict[str, float]]:
"""토적표·유토곡선·운반표가 **계산에** 쓸 계수 — 암은 구성비 가중 C (㉱ (나) · 2026-09-14).
B06 의 암은 한 종류 **자리표시**(`ripping_rock` · 옛 자료 `blasting_rock`)이고 암질은 설계내역에서
구성비로 정함(8-1 사용자 확정). 그래서 구성비와 갈래별 시공법이 **다 서면** 암 두 칸을
Σ몫 × C(시공법)로 갈음 — 몫은 흙깎기와 같은 안분(준 비율끼리 · 합이 100 이 아니어도 안분).
⚠ 구성비가 비거나 시공법이 빠진 갈래가 있으면 **종전 값** — 인계가 막힘 사유를 내고, 지어낸 계수를 안 씀.
⚠ 화면 「무엇을 골랐나」(`earthwork_conversion_choices`)는 갈래별 값 그대로 — 여기 값을 보이면 안 됨.
"""
resolved = earthwork_conversion_factors(settings)
ratios = settings.get("rock_ratios_pct") or {}
shares = {
name: float(ratios.get(name) or 0)
for name in rock_classes(settings)
if name != "토사" and float(ratios.get(name) or 0) > 0
}
kinds = {name: ROCK_METHOD_KINDS.get(rock_method(settings, name) or "") for name in shares}
if not shares or not all(kinds.values()):
return resolved
total = sum(shares.values())
mixed = sum(shares[name] / total * resolved[kind]["compacted"] for name, kind in kinds.items())
for kind in ROCK_METHOD_KINDS.values():
resolved[kind]["compacted"] = mixed
return resolved
def earthwork_conversion_choices(settings: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""갈래별 「무엇을 골랐나」 — 화면이 기본값과 고른 값을 갈라 보이는 데 쓴다.
`{갈래: {"compacted", "default", "chosen", "in_range", "range", "reason"}}`.
`chosen` 이 거짓이면 기본값이 선 것이고, `in_range` 가 거짓이면 품셈 범위 밖이라
사유가 있어야 하는 자리다. **범위 밖이라고 막지 않는다**(품셈 원칙이 토질시험이다).
"""
override = settings.get("conversion_factors_override")
override = override if isinstance(override, dict) else {}
resolved = earthwork_conversion_factors(settings)
choices: dict[str, dict[str, Any]] = {}
for kind, entry in resolved.items():
default = float(EARTHWORK_CONVERSION_FACTORS[kind]["compacted"])
value = float(entry["compacted"])
low, high = EARTHWORK_CONVERSION_C_RANGES.get(kind, (None, None))
entry_override = override.get(kind)
reason = entry_override.get("reason") if isinstance(entry_override, dict) else None
choices[kind] = {
"compacted": value,
"default": default,
"chosen": value != default,
"in_range": low is None or low <= value <= high,
"range": [low, high] if low is not None else None,
"reason": str(reason) if reason else None,
}
return choices
def haul_equipment_limits(settings: dict[str, Any]) -> list[tuple[str, float | None]]:
"""이 프로젝트가 쓸 운반장비 거리 경계 — 기본값 위에 **도쟈 한계거리만** 얹는다.
⚠ 유토곡선(B06)·운반표가 같은 값으로 서야 한다 — 읽는 자리마다 상수를 들지 말고
이 함수를 거친다.
⚠ 종무대 경계 이하 값은 받지 않는다(도쟈 몫이 사라짐) — 기본값이 선다.
"""
limits = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
value = settings.get("dozer_haul_limit_m")
if (
isinstance(value, (int, float))
and not isinstance(value, bool)
and float(value) > float(limits["free_haul"] or 0.0)
):
limits["dozer"] = float(value)
return [(key, limits[key]) for key, _ in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M]
def haul_limit_choice(settings: dict[str, Any]) -> dict[str, Any]:
"""도쟈 한계거리 칸 — 화면이 지금 값·기본값·근거를 함께 보인다."""
default = dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["dozer"]
value = dict(haul_equipment_limits(settings))["dozer"]
return {
"value": value,
"default": default,
"chosen": value != default,
"basis": EARTHWORK_HAUL_LIMIT_BASIS["dozer"],
"free_haul_m": dict(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)["free_haul"],
"free_haul_basis": EARTHWORK_HAUL_LIMIT_BASIS["free_haul"],
}
def application_ratio(settings: dict[str, Any], key: str) -> float:
"""반영률을 0~1 로. 없으면 100 %(=1.0) — 실무 관측치를 기본값으로 쓰지 않는다."""
raw = (settings.get("application_ratios_pct") or {}).get(key, 100)
try:
return float(raw) / 100.0
except (TypeError, ValueError):
return 1.0