Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""구조물도 **양식형 항목** 읽기 — 양식 파일 + 구조물 제원 → 식 풀이기에 넘길 장 한 벌.
|
||||
|
||||
양식은 `resources/library_structure/<type_id>.json`(4장 프로그램 기본 자리 · 명세 13장 식 칸 계약).
|
||||
풀이는 여기 없음 — `B08_Quantity_Engine_Formula.evaluate_sheets`(TS 한 벌을 Node 로)가 풂.
|
||||
|
||||
⚠ 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않음 — 제원은 `vars` 로 들어가고 같은 식이
|
||||
수량을 다시 냄(명세 16장 「한 조합 + 규격별 수량표」).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
TEMPLATE_DIR = ROOT / "resources" / "library_structure"
|
||||
|
||||
|
||||
def load_template(type_id: str) -> dict[str, Any] | None:
|
||||
"""프로그램 기본 양식. 없는 종류면 `None` — 그 종류는 지금 전개(고정형 모양)로 섬."""
|
||||
path = TEMPLATE_DIR / f"{type_id}.json"
|
||||
if not path.is_file():
|
||||
return None
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _typed(value: Any, default: Any) -> Any:
|
||||
"""제원 값을 양식 기본값과 같은 꼴로 — 수 칸은 수, 글 칸은 글."""
|
||||
if isinstance(default, (int, float)) and not isinstance(default, bool):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return str(value)
|
||||
|
||||
|
||||
def template_vars(
|
||||
template: dict[str, Any],
|
||||
structure: dict[str, Any],
|
||||
judged_slope: float,
|
||||
settings: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""양식이 적은 제원 칸을 구조물·산출 조건에서 채움. 빈 칸은 양식 기본값.
|
||||
|
||||
⚠ 전면 기울기는 **전개가 판정한 값**을 받음 — 표준경사 판정(성절토·직고)은 식 언어 밖이라
|
||||
판정 한 벌(`face_slope_ratio`)을 그대로 씀.
|
||||
"""
|
||||
options = structure.get("options") or {}
|
||||
values: dict[str, Any] = {}
|
||||
for name, spec in (template.get("vars") or {}).items():
|
||||
default = spec.get("default")
|
||||
source = spec.get("source")
|
||||
if source == "judged_face_slope":
|
||||
values[name] = float(judged_slope)
|
||||
continue
|
||||
if source:
|
||||
values[name] = float(structure.get(source) or 0.0)
|
||||
continue
|
||||
if "option" in spec:
|
||||
raw = options.get(spec["option"])
|
||||
else:
|
||||
raw = (settings or {}).get(spec.get("setting"))
|
||||
values[name] = default if raw in (None, "") else _typed(raw, default)
|
||||
return values
|
||||
|
||||
|
||||
def template_sheet(template: dict[str, Any], values: dict[str, Any]) -> dict[str, Any]:
|
||||
"""식 풀이기가 받는 장 한 벌 — 줄·표는 양식 그대로, 제원만 끼움."""
|
||||
return {
|
||||
"rows": template.get("rows") or [],
|
||||
"vars": values,
|
||||
"tables": template.get("tables") or {},
|
||||
}
|
||||
@@ -14,21 +14,39 @@
|
||||
* ⚠ `eval` 을 쓰지 않음 — 식은 사용자·라이브러리에서 오는 글이라 직접 짠 파서로만 읽음.
|
||||
* ========================================================================== */
|
||||
|
||||
export type RoundingMode = "floor" | "round" | "ceil" | "none" | "round_half_even";
|
||||
/**
|
||||
* 반올림 갈래 — 엑셀 대응(명세 13장 대응표): `INT`=floor · `ROUNDDOWN`=trunc · `ROUNDUP`=ceil_away ·
|
||||
* `ROUND`=round(사사오입). ⚠ 양수에서는 floor=trunc · ceil=ceil_away 라 **음수에서만 갈림**.
|
||||
*/
|
||||
export type RoundingMode =
|
||||
"floor" | "trunc" | "round" | "ceil_away" | "ceil" | "none" | "round_half_even";
|
||||
|
||||
export interface FormulaRounding {
|
||||
mode: RoundingMode;
|
||||
digits: number;
|
||||
}
|
||||
|
||||
/** 갈 곳 — 이중계상 경계를 줄 단위로(명세 13장 Ⓑ). ⛔ 빠지면 오류, 기본값 없음. */
|
||||
export const DESTINATIONS = [
|
||||
"earthwork",
|
||||
"material",
|
||||
"unit_price",
|
||||
"reference",
|
||||
"haul_deduction",
|
||||
] as const;
|
||||
|
||||
/** 식 칸 한 줄 — 명세 13장 「한 줄이 들고 갈 칸」. */
|
||||
export interface FormulaRow {
|
||||
seq: number;
|
||||
name: string;
|
||||
/** 규격. `{제원이름}` 은 그 제원 값으로 바뀜 — 이름은 고정하고 종류는 여기로(명세 13장 Ⓒ). */
|
||||
spec?: string;
|
||||
/** 기계가 푸는 식(정본). 비면 고정형 — `amount` 를 박힌 값으로 씀. */
|
||||
formula?: string | null;
|
||||
formula_text?: string;
|
||||
/** 줄이 서느냐 — 거짓이면 그 줄은 **안 섬**(0 이 아님, 명세 13장 Ⓐ). */
|
||||
when?: string | null;
|
||||
destination?: string;
|
||||
refs?: Record<string, number>;
|
||||
vars?: Record<string, number | string>;
|
||||
amount?: string | number | null;
|
||||
@@ -53,10 +71,15 @@ export interface FormulaSheet {
|
||||
export interface FormulaRowResult {
|
||||
seq: number;
|
||||
name: string;
|
||||
/** 반올림 뒤 값(십진 문자열). 오류면 `null`. */
|
||||
/** `{제원}` 을 채운 규격. */
|
||||
spec: string;
|
||||
/** 반올림 뒤 값(십진 문자열). 오류·안 섬이면 `null`. */
|
||||
amount: string | null;
|
||||
/** 반올림 전 값 — 어느 자리에서 갈렸는지 되짚는 용. */
|
||||
raw: string | null;
|
||||
/** `when` 이 거짓이라 안 선 줄 — 화면은 「안 섬」과 `reason` 을 보임. */
|
||||
skipped: boolean;
|
||||
reason: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -133,6 +156,8 @@ function toInteger(x: Frac, mode: RoundingMode): bigint {
|
||||
const negative = x.n < 0n;
|
||||
if (mode === "floor") return negative ? q - 1n : q;
|
||||
if (mode === "ceil") return negative ? q : q + 1n;
|
||||
if (mode === "trunc") return q; // 엑셀 ROUNDDOWN — 0 쪽
|
||||
if (mode === "ceil_away") return negative ? q - 1n : q + 1n; // 엑셀 ROUNDUP — 0 에서 먼 쪽
|
||||
const twice = abs(r) * 2n;
|
||||
if (twice === x.d && mode === "round_half_even")
|
||||
return q % 2n === 0n ? q : negative ? q - 1n : q + 1n;
|
||||
@@ -336,8 +361,13 @@ function parse(source: string): Node {
|
||||
|
||||
type Value = Frac | string | boolean;
|
||||
|
||||
/** 안 선 줄·오류 난 줄을 가리키는 이름 — 쓰이는 순간 그 까닭으로 막음. */
|
||||
class Blocked {
|
||||
constructor(readonly message: string) {}
|
||||
}
|
||||
|
||||
interface Scope {
|
||||
names: Map<string, Value>;
|
||||
names: Map<string, Value | Blocked>;
|
||||
tables: Record<string, LookupTable>;
|
||||
}
|
||||
|
||||
@@ -407,6 +437,8 @@ function evaluate(node: Node, scope: Scope): Value {
|
||||
case "name": {
|
||||
const value = scope.names.get(node.name);
|
||||
if (value === undefined) throw new FormulaError(`모르는 이름: ${node.name}`);
|
||||
// 안 선 줄·오류 난 줄은 **쓰일 때** 막음 — `when` 이 그 이름을 안 쓰면 줄은 그대로 판정됨.
|
||||
if (value instanceof Blocked) throw new FormulaError(value.message);
|
||||
return value;
|
||||
}
|
||||
case "unary": {
|
||||
@@ -459,39 +491,82 @@ function evaluate(node: Node, scope: Scope): Value {
|
||||
}
|
||||
}
|
||||
|
||||
const ROUNDING_MODES = new Set<string>([
|
||||
"floor",
|
||||
"trunc",
|
||||
"round",
|
||||
"ceil_away",
|
||||
"ceil",
|
||||
"none",
|
||||
"round_half_even",
|
||||
]);
|
||||
|
||||
/** `{제원}` 을 제원 값으로 — 모르는 이름은 그대로 두지 않고 오류(조인 키가 흔들림). */
|
||||
function fillSpec(spec: string, vars: Record<string, number | string>): string {
|
||||
return spec.replace(/\{([^{}]+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) throw new FormulaError(`규격의 모르는 제원: ${key}`);
|
||||
return typeof value === "number" ? fracToString(toFrac(value)) : value;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 장 한 벌을 줄 차례대로 풂. 오류는 **그 줄에만** 적고 다음 줄은 계속 풂 —
|
||||
* 오류 난 줄을 가리키는 줄은 「앞 줄 오류」로 막힘(0 으로 때우지 않음).
|
||||
* 오류 난 줄·안 선 줄을 가리키는 줄은 막힘(0 으로 때우지 않음, 명세 13장 Ⓐ).
|
||||
*/
|
||||
export function evaluateSheet(sheet: FormulaSheet): FormulaRowResult[] {
|
||||
const rows = [...(sheet.rows ?? [])].sort((a, b) => a.seq - b.seq);
|
||||
const done = new Map<number, { value: Frac | null; name: string }>();
|
||||
const done = new Map<number, { value: Frac | null; name: string; skipped: boolean }>();
|
||||
const results: FormulaRowResult[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const result: FormulaRowResult = {
|
||||
seq: row.seq,
|
||||
name: row.name,
|
||||
spec: row.spec ?? "",
|
||||
amount: null,
|
||||
raw: null,
|
||||
skipped: false,
|
||||
reason: null,
|
||||
error: null,
|
||||
};
|
||||
try {
|
||||
if (done.has(row.seq)) throw new FormulaError(`같은 차례 번호가 둘: ${row.seq}`);
|
||||
// ⛔ 갈 곳 기본값 없음 — 빠진 줄이 자재총괄에서 조용히 사라진 결함이 실재함(명세 13장 Ⓑ).
|
||||
if (!row.destination) throw new FormulaError("갈 곳(destination)이 없음");
|
||||
if (!(DESTINATIONS as readonly string[]).includes(row.destination)) {
|
||||
throw new FormulaError(`모르는 갈 곳: ${row.destination}`);
|
||||
}
|
||||
if (row.rounding && !ROUNDING_MODES.has(row.rounding.mode)) {
|
||||
throw new FormulaError(`모르는 반올림: ${row.rounding.mode}`);
|
||||
}
|
||||
const vars = { ...(sheet.vars ?? {}), ...(row.vars ?? {}) };
|
||||
result.spec = fillSpec(row.spec ?? "", vars);
|
||||
const names = new Map<string, Value | Blocked>();
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
names.set(key, typeof value === "number" ? toFrac(value) : value);
|
||||
}
|
||||
for (const [key, seq] of Object.entries(row.refs ?? {})) {
|
||||
if (seq >= row.seq) throw new FormulaError(`앞 줄만 가리킬 수 있음: ${key} → ${seq}`);
|
||||
const target = done.get(seq);
|
||||
if (!target) throw new FormulaError(`없는 줄: ${key} → ${seq}`);
|
||||
names.set(
|
||||
key,
|
||||
target.value ??
|
||||
new Blocked(`앞 줄 ${seq}(${target.name}) ${target.skipped ? "안 섬" : "오류"}`),
|
||||
);
|
||||
}
|
||||
const scope: Scope = { names, tables: sheet.tables ?? {} };
|
||||
if (row.when && row.when.trim() && !truthy(evaluate(parse(row.when), scope))) {
|
||||
result.skipped = true;
|
||||
result.reason = `조건이 거짓: ${row.when}`;
|
||||
done.set(row.seq, { value: null, name: row.name, skipped: true });
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
let raw: Frac;
|
||||
if (row.formula && row.formula.trim()) {
|
||||
const names = new Map<string, Value>();
|
||||
for (const [key, value] of Object.entries({ ...(sheet.vars ?? {}), ...(row.vars ?? {}) })) {
|
||||
names.set(key, typeof value === "number" ? toFrac(value) : value);
|
||||
}
|
||||
for (const [key, seq] of Object.entries(row.refs ?? {})) {
|
||||
if (seq >= row.seq) throw new FormulaError(`앞 줄만 가리킬 수 있음: ${key} → ${seq}`);
|
||||
const target = done.get(seq);
|
||||
if (!target) throw new FormulaError(`없는 줄: ${key} → ${seq}`);
|
||||
if (!target.value) throw new FormulaError(`앞 줄 ${seq}(${target.name}) 오류`);
|
||||
names.set(key, target.value);
|
||||
}
|
||||
raw = asFrac(evaluate(parse(row.formula), { names, tables: sheet.tables ?? {} }));
|
||||
raw = asFrac(evaluate(parse(row.formula), scope));
|
||||
} else {
|
||||
// 고정형 — 박힌 값을 그대로(명세 13장 「양식형 ↔ 고정형」).
|
||||
if (row.amount === null || row.amount === undefined || row.amount === "") {
|
||||
@@ -502,10 +577,10 @@ export function evaluateSheet(sheet: FormulaSheet): FormulaRowResult[] {
|
||||
const value = applyRounding(raw, row.rounding);
|
||||
result.raw = fracToString(raw);
|
||||
result.amount = fracToString(value);
|
||||
done.set(row.seq, { value, name: row.name });
|
||||
done.set(row.seq, { value, name: row.name, skipped: false });
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
done.set(row.seq, { value: null, name: row.name });
|
||||
done.set(row.seq, { value: null, name: row.name, skipped: false });
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"library_tier": "program",
|
||||
"item_kind": "form",
|
||||
"type_id": "masonry_wet",
|
||||
"name": "돌쌓기(찰)",
|
||||
"note": "구조물도 양식형 항목 첫 벌(PLAN 3장 ② · 명세 13장 식 칸 계약). 값은 지금 전개(`B08_Quantity_Engine_UnitQuantity.stone_masonry` + 기초잡석)와 같게 둠(판정 Ⓑ). 규격(높이·뒷길이·돌종류)마다 항목을 늘리지 않고 제원 vars 와 표 한 벌로 수량이 다시 남(명세 16장).",
|
||||
"differs_from_engine": [
|
||||
"원문 「-」 칸(야면석 75·깬돌 25·견치돌 25/30 고임돌)에서 전개는 고임돌 줄을 안 세우고 채집석에 0 을 더함. 이 양식은 고임돌 줄이 「표 칸이 비어 있음」 오류로 서고 채집석도 막힘 — 명세 13장 Ⓐ(안 선 줄을 0 으로 치지 않음)을 따른 것",
|
||||
"돌 줄 이름 — 전개는 돌종류 이름(야면석·호박돌…/돌), 양식은 이름 「돌」 고정 + spec 에 종류(명세 13장 Ⓒ)",
|
||||
"「실무 관행」 계수를 고르면 전개는 돌종류까지 지워 돌 줄을 「돌」·계산식 무게로 세움(야면석을 골라도). 관행은 계수 열만 바꾸는 칸이라 전개 쪽 결함으로 보고 양식은 종류를 지킴 — 판정 요청"
|
||||
],
|
||||
"vars": {
|
||||
"H": { "label": "높이(m)", "source": "height_m" },
|
||||
"L": { "label": "연장(m)", "source": "length_m" },
|
||||
"N": {
|
||||
"label": "전면 기울기 1:n",
|
||||
"source": "judged_face_slope",
|
||||
"note": "사용자 값이 있으면 그 값, 없으면 품셈 13-4-4 [주]⑪ 표준경사를 전개가 판정해 넘김"
|
||||
},
|
||||
"L3": { "label": "뒷길이(㎝)", "option": "back_len_cm", "default": 45 },
|
||||
"돌종류": { "label": "돌 종류", "option": "stone_kind", "default": "" },
|
||||
"계수기준": { "label": "야면석 계수", "option": "stone_coeff_basis", "default": "" },
|
||||
"TOP_IN": {
|
||||
"label": "상부 두께(m) — 0 은 안 정함",
|
||||
"option": "thickness_top_m",
|
||||
"default": 0
|
||||
},
|
||||
"BOT_IN": {
|
||||
"label": "하부 두께(m) — 0 은 안 정함",
|
||||
"option": "thickness_bottom_m",
|
||||
"default": 0
|
||||
},
|
||||
"기초": { "label": "기초(기초유·기초버림)", "option": "foundation", "default": "" },
|
||||
"BLIND": { "label": "버림 콘크리트(넣음·안 넣음)", "option": "blinding_concrete", "default": "넣음" },
|
||||
"SUPPLY": { "label": "돌 조달(채집·구입)", "option": "stone_supply", "default": "채집" },
|
||||
"MPA": { "label": "채움 콘크리트 강도(MPa)", "option": "fill_concrete_mpa", "default": "210" },
|
||||
"HOLE_DIA": { "label": "물구멍 지름(㎜)", "option": "weep_hole_diameter_mm", "default": 50 },
|
||||
"HOLE_AREA": {
|
||||
"label": "물구멍 1개소당 벽면적(㎡)",
|
||||
"option": "weep_hole_area_m2",
|
||||
"default": 2,
|
||||
"candidates": [
|
||||
{ "value": 2.5, "source": "소광리 07-구조도 「돌기슭막이(45)찰」 실무 관측 — ea/2.5㎡" }
|
||||
]
|
||||
},
|
||||
"RUBBLE_T": {
|
||||
"label": "기초잡석 두께(m)",
|
||||
"setting": "rubble_base_thickness_m",
|
||||
"default": 0.2
|
||||
}
|
||||
},
|
||||
"tables": {
|
||||
"고임돌표": {
|
||||
"note": "품셈 13-4-3 고임돌 ㎥/㎡ · 「참고자료」 열은 돌종류 미지정·실무 관행일 때(건설품셈 참고자료) · null 은 원문 「-」",
|
||||
"keys": [25, 30, 35, 45, 55, 60, 75],
|
||||
"columns": {
|
||||
"야면석·호박돌": [0.06, 0.07, 0.09, 0.11, 0.14, 0.15, null],
|
||||
"깬잡석": [0.09, 0.11, 0.13, 0.16, 0.19, 0.21, 0.26],
|
||||
"깬돌": [null, 0.1, 0.12, 0.15, 0.18, 0.2, 0.25],
|
||||
"견치돌": [null, null, 0.12, 0.15, 0.18, 0.2, 0.25],
|
||||
"참고자료": [null, 0.1, 0.12, 0.15, 0.18, 0.2, 0.25]
|
||||
}
|
||||
},
|
||||
"채움표": {
|
||||
"note": "품셈 13-4-4 [주]① 채움 콘크리트 ㎥/㎡",
|
||||
"keys": [25, 30, 35, 45, 55, 60, 75],
|
||||
"columns": {
|
||||
"야면석·호박돌": [0.08, 0.1, 0.12, 0.15, 0.18, 0.2, 0.25],
|
||||
"깬잡석": [0.11, 0.14, 0.16, 0.2, 0.25, 0.27, 0.34],
|
||||
"깬돌": [0.11, 0.14, 0.16, 0.2, 0.25, 0.27, 0.34],
|
||||
"견치돌": [0.11, 0.14, 0.16, 0.2, 0.25, 0.27, 0.34],
|
||||
"참고자료": [0.11, 0.14, 0.16, 0.2, 0.25, 0.27, 0.34]
|
||||
}
|
||||
},
|
||||
"돌중량표": {
|
||||
"note": "야면석 돌중량 ton/㎡ — 울진 소광 원단위 라이브러리 실무 관측(35·45·55㎝만). 다른 종류는 계산식",
|
||||
"keys": [35, 45, 55],
|
||||
"columns": { "야면석": [0.575, 0.88, 1.1] }
|
||||
}
|
||||
},
|
||||
"rows": [
|
||||
{
|
||||
"seq": 1,
|
||||
"name": "돌쌓기",
|
||||
"spec": "",
|
||||
"formula": "H*L*SQRT(1+N^2)",
|
||||
"formula_text": "정면적 × √(1+n²) — 비탈면적",
|
||||
"unit": "㎡",
|
||||
"destination": "unit_price",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 2,
|
||||
"name": "입적",
|
||||
"spec": "",
|
||||
"formula": "H*L*(IF(TOP_IN>0,TOP_IN,L3/100+0.3)+IF(BOT_IN>0,BOT_IN,IF(TOP_IN>0,TOP_IN,L3/100+0.3)+0.3*MAX(H-1,0)))/2",
|
||||
"formula_text": "정면적 × 평균두께 · 상부 = 뒷길이 + 0.30 · 하부 = 상부 + 0.30×(H−1) (실무 구조물도 식, 두께를 넣으면 그 값)",
|
||||
"unit": "㎥",
|
||||
"destination": "reference",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 3,
|
||||
"name": "고임돌",
|
||||
"spec": "",
|
||||
"formula": "A*LOOKUP_EXACT(고임돌표,L3,IF(돌종류='','참고자료',IF(계수기준='실무 관행','참고자료',돌종류)))",
|
||||
"formula_text": "돌쌓기 × 고임돌 원단위(뒷길이·돌종류, 품셈 13-4-3)",
|
||||
"refs": { "A": 1 },
|
||||
"unit": "㎥",
|
||||
"destination": "material",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 4,
|
||||
"name": "석적",
|
||||
"spec": "",
|
||||
"formula": "H*L*L3/100*0.77",
|
||||
"formula_text": "정면적 × 뒷길이 × 0.77(채움률) — 소광리 시트에만 있는 줄",
|
||||
"unit": "㎥",
|
||||
"destination": "reference",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 5,
|
||||
"name": "돌",
|
||||
"spec": "{돌종류}",
|
||||
"formula": "A*IF(돌종류='야면석·호박돌',LOOKUP_EXACT(돌중량표,L3,'야면석'),L3/100*0.77*2.65)",
|
||||
"formula_text": "돌쌓기 × 돌중량 — 뒷길이 × 0.77 × 2.65(확정 5차, 잠정) · 야면석은 실무 관측표",
|
||||
"refs": { "A": 1 },
|
||||
"unit": "ton",
|
||||
"destination": "material",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 6,
|
||||
"name": "막자갈",
|
||||
"spec": "",
|
||||
"formula": "(0.3+0.45)/2*H*L",
|
||||
"formula_text": "(뒷채움 상 0.30 + 하 0.45) ÷ 2 × H × 연장 — 정본 여섯 탭 공통값",
|
||||
"unit": "㎥",
|
||||
"destination": "material",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 7,
|
||||
"name": "채움콘크리트",
|
||||
"spec": "{MPA}",
|
||||
"formula": "A*LOOKUP_EXACT(채움표,L3,IF(돌종류='','참고자료',IF(계수기준='실무 관행','참고자료',돌종류)))",
|
||||
"formula_text": "돌쌓기 × 채움 콘크리트 원단위(뒷길이·돌종류, 품셈 13-4-4 [주]①)",
|
||||
"refs": { "A": 1 },
|
||||
"unit": "㎥",
|
||||
"destination": "material",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 8,
|
||||
"name": "모르터",
|
||||
"spec": "",
|
||||
"formula": "A*0.009",
|
||||
"formula_text": "돌쌓기 × 0.009 ㎥/㎡ (줄눈)",
|
||||
"refs": { "A": 1 },
|
||||
"unit": "㎥",
|
||||
"destination": "unit_price",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 9,
|
||||
"name": "물구멍관",
|
||||
"spec": "Ø{HOLE_DIA}",
|
||||
"formula": "A/HOLE_AREA*0.5",
|
||||
"formula_text": "돌쌓기 ÷ 개소당 벽면적 × 0.5 m/개소 (실무 관측 2㎡ · 법은 2~3㎡당 1개소 이상)",
|
||||
"refs": { "A": 1 },
|
||||
"unit": "m",
|
||||
"destination": "material",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 10,
|
||||
"name": "채집석",
|
||||
"spec": "",
|
||||
"when": "SUPPLY<>'구입'",
|
||||
"formula": "S+W+R",
|
||||
"formula_text": "석적 + 고임돌 + 막자갈 — 현장 채집분 · 여기서 빼지 않음(사토에서 한 번만 뺌)",
|
||||
"refs": { "S": 4, "W": 3, "R": 6 },
|
||||
"unit": "㎥",
|
||||
"destination": "haul_deduction",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 11,
|
||||
"name": "버림콘크리트",
|
||||
"spec": "",
|
||||
"when": "IF(BLIND='안 넣음',0,IF(BLIND='안넣음',0,IF(BLIND='제외',0,1)))",
|
||||
"formula": "IF(BOT_IN>0,BOT_IN,IF(TOP_IN>0,TOP_IN,L3/100+0.3)+0.3*MAX(H-1,0))*L*0.1",
|
||||
"formula_text": "하단 길이(하부 두께) × 연장 × 두께 0.10m — KDS 44 90 00 · 폭은 잡석다짐 폭(KCS 34 50 05)",
|
||||
"unit": "㎥",
|
||||
"destination": "material",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 12,
|
||||
"name": "터파기",
|
||||
"spec": "",
|
||||
"formula": "(IF(기초='기초유',0.5*0.9,IF(기초='기초버림',0.1*0.7,0))+H*((IF(TOP_IN>0,TOP_IN,L3/100+0.3)+IF(BOT_IN>0,BOT_IN,IF(TOP_IN>0,TOP_IN,L3/100+0.3)+0.3*MAX(H-1,0)))/2+0.2))*L",
|
||||
"formula_text": "기초분(기초유 0.5×0.9 · 기초버림 0.1×0.7) + H × (평균두께 + 0.2) × 연장 — 실무 정본 식",
|
||||
"unit": "㎥",
|
||||
"destination": "earthwork",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 13,
|
||||
"name": "되메우기",
|
||||
"spec": "",
|
||||
"formula": "(IF(기초='기초유',0.5,IF(기초='기초버림',0.1,0))+H)*0.2*L",
|
||||
"formula_text": "(기초깊이 + H) × 0.2 × 연장",
|
||||
"unit": "㎥",
|
||||
"destination": "earthwork",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 14,
|
||||
"name": "잔토처리",
|
||||
"spec": "",
|
||||
"formula": "E-B",
|
||||
"formula_text": "터파기 − 되메우기",
|
||||
"refs": { "E": 12, "B": 13 },
|
||||
"unit": "㎥",
|
||||
"destination": "earthwork",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
},
|
||||
{
|
||||
"seq": 15,
|
||||
"name": "기초잡석",
|
||||
"spec": "",
|
||||
"when": "IF(BLIND='안 넣음',0,IF(BLIND='안넣음',0,IF(BLIND='제외',0,RUBBLE_T>0)))",
|
||||
"formula": "B*(RUBBLE_T/0.1)",
|
||||
"formula_text": "버림 × (잡석두께 ÷ 버림두께 0.1) — 폭이 같음(KCS 34 50 05) · 두께는 확정 3차 ②",
|
||||
"refs": { "B": 11 },
|
||||
"unit": "㎥",
|
||||
"destination": "unit_price",
|
||||
"rounding": { "mode": "none", "digits": 0 },
|
||||
"source": "library"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27,6 +27,8 @@ pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가
|
||||
|
||||
|
||||
def _solve(rows: list[dict], vars: dict | None = None, tables: dict | None = None) -> list[dict]:
|
||||
# 갈 곳은 계약상 필수(기본값 없음) — 값 시험에서는 「보여주기만」으로 채워 넣음.
|
||||
rows = [{"destination": "reference", **row} for row in rows]
|
||||
result = evaluate_sheets([{"rows": rows, "vars": vars or {}, "tables": tables or {}}])
|
||||
assert result is not None, "Node 풀이가 안 돌았다"
|
||||
return result[0]
|
||||
@@ -75,6 +77,18 @@ def test_반올림_갈래() -> None:
|
||||
assert row["amount"] == "0." + "3" * 30
|
||||
|
||||
|
||||
def test_엑셀_대응표_음수에서_갈리는_넷() -> None:
|
||||
"""명세 13장 대응표 — 양수에서는 같아 시험을 빠져나가던 자리(브레인 검산)."""
|
||||
갈래 = {"floor": "-3", "trunc": "-2", "ceil_away": "-3", "ceil": "-2", "round": "-3"}
|
||||
for mode, expected in 갈래.items():
|
||||
assert _amount("-2.5", {"mode": mode, "digits": 0}) == expected, mode
|
||||
assert _amount("-2.5", {"mode": "round_half_even", "digits": 0}) == "-2"
|
||||
# 양수에서는 둘씩 같음 — 그래서 음수를 따로 봄.
|
||||
assert _amount("2.5", {"mode": "trunc", "digits": 0}) == "2"
|
||||
assert _amount("2.5", {"mode": "ceil_away", "digits": 0}) == "3"
|
||||
assert "모르는 반올림" in _one("1", {"mode": "banker", "digits": 0})["error"]
|
||||
|
||||
|
||||
def test_함수() -> None:
|
||||
assert _amount("2*1*SQRT(1+0.3^2)", {"mode": "round", "digits": 3}) == "2.088"
|
||||
assert _amount("SQRT(2.25)") == "1.5"
|
||||
@@ -162,6 +176,57 @@ def test_참조는_반올림_뒤_값을_받는다() -> None:
|
||||
assert result[1]["amount"] == "45"
|
||||
|
||||
|
||||
def test_when_거짓이면_줄이_안_서고_가리키면_막힌다() -> None:
|
||||
"""명세 13장 Ⓐ — 「안 섬」은 0 이 아님. 가리키는 뒷줄은 오류, 안 쓰면 영향 없음."""
|
||||
rows = [
|
||||
{"seq": 1, "name": "버림", "formula": "2", "when": "BLIND<>'안 넣음'"},
|
||||
{"seq": 2, "name": "잡석", "formula": "B*2", "refs": {"B": 1}},
|
||||
{
|
||||
"seq": 3,
|
||||
"name": "잡석_조건",
|
||||
"formula": "B*2",
|
||||
"refs": {"B": 1},
|
||||
"when": "BLIND<>'안 넣음'",
|
||||
},
|
||||
{"seq": 4, "name": "무관", "formula": "7", "refs": {"B": 1}},
|
||||
]
|
||||
넣음 = _solve(rows, {"BLIND": "넣음"})
|
||||
assert [row["amount"] for row in 넣음] == ["2", "4", "4", "7"]
|
||||
안넣음 = _solve(rows, {"BLIND": "안 넣음"})
|
||||
assert 안넣음[0]["skipped"] and 안넣음[0]["amount"] is None and 안넣음[0]["error"] is None
|
||||
assert "조건이 거짓" in 안넣음[0]["reason"]
|
||||
assert "앞 줄 1(버림) 안 섬" in 안넣음[1]["error"]
|
||||
assert 안넣음[2]["skipped"] and 안넣음[2]["error"] is None # 조건이 먼저라 막히지 않음
|
||||
assert 안넣음[3]["amount"] == "7" # 이름을 안 쓰면 영향 없음
|
||||
|
||||
|
||||
def test_갈_곳이_빠지면_오류_기본값_없음() -> None:
|
||||
"""명세 13장 Ⓑ ⛔⛔ — 기본값이 빠진 줄을 조용히 사라지게 한 결함이 실재함."""
|
||||
rows = [
|
||||
{"seq": 1, "name": "빠짐", "formula": "1"},
|
||||
{"seq": 2, "name": "모름", "formula": "1", "destination": "somewhere"},
|
||||
{"seq": 3, "name": "맞음", "formula": "1", "destination": "material"},
|
||||
]
|
||||
result = evaluate_sheets([{"rows": rows}])[0]
|
||||
assert "갈 곳(destination)이 없음" in result[0]["error"]
|
||||
assert "모르는 갈 곳" in result[1]["error"]
|
||||
assert result[2]["amount"] == "1"
|
||||
|
||||
|
||||
def test_이름은_고정_규격이_제원을_따른다() -> None:
|
||||
"""명세 13장 Ⓒ — 조인 키(이름+규격)가 흔들리지 않게 이름은 고정."""
|
||||
rows = [
|
||||
{"seq": 1, "name": "돌", "spec": "{돌종류}", "formula": "1"},
|
||||
{"seq": 2, "name": "물구멍관", "spec": "Ø{DIA}", "formula": "1"},
|
||||
{"seq": 3, "name": "모름", "spec": "{없는칸}", "formula": "1"},
|
||||
]
|
||||
result = _solve(rows, {"돌종류": "깬돌", "DIA": 50})
|
||||
assert (result[0]["name"], result[0]["spec"]) == ("돌", "깬돌")
|
||||
assert result[1]["spec"] == "Ø50"
|
||||
assert "규격의 모르는 제원" in result[2]["error"]
|
||||
assert _solve(rows[:1], {"돌종류": ""})[0]["spec"] == "" # 안 고르면 규격 빔 — 매칭 성공 아님
|
||||
|
||||
|
||||
def test_고정형과_줄_제원() -> None:
|
||||
rows = [
|
||||
{"seq": 1, "name": "박힌값", "amount": "2.088"},
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""찰쌓기 양식 = 지금 전개 — 값이 한 줄도 안 갈리는지 (2026-09-13, PLAN 3장 ② ③).
|
||||
|
||||
판정 Ⓑ 「첫 양식 값은 지금 전개와 같게」. 양식(`resources/library_structure/masonry_wet.json`)을
|
||||
식 풀이기(TS → Node)로 풀어 낸 값이 `build_table`(돌쌓기 전개 + 기초잡석)과 같아야 함.
|
||||
어긋나면 **양식이 틀린 것**.
|
||||
|
||||
⚠ 전개는 부동소수, 양식은 분수 — 상대 1e-9 안이면 같은 값으로 봄.
|
||||
⚠ 줄이 「안 서는」 경우(버림 안 넣음 · 채집석 구입)는 `when` 으로 — 안 선 줄은 전개에도 없어야 함.
|
||||
⚠ 원문 「-」 칸은 일부러 다름(양식 `differs_from_engine`) — 여기서는 그 규격을 안 넣음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity.B08_Quantity_Engine_Formula import evaluate_sheets # noqa: E402
|
||||
from B08_Quantity.B08_Quantity_Engine_StructureTemplate import ( # noqa: E402
|
||||
load_template,
|
||||
template_sheet,
|
||||
template_vars,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import ( # noqa: E402
|
||||
build_table,
|
||||
face_slope_ratio,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _structure(height: float, length: float, **options) -> dict:
|
||||
return {
|
||||
"structure_id": "s1",
|
||||
"type_id": "masonry_wet",
|
||||
"name": "돌쌓기(찰)",
|
||||
"start_m": 100.0,
|
||||
"end_m": 100.0 + length,
|
||||
"length_m": length,
|
||||
"height_m": height,
|
||||
"options": {"height_m": height, "length_m": length, **options},
|
||||
}
|
||||
|
||||
|
||||
CASES = [
|
||||
dict(height=h, length=10.0, back_len_cm=l3, stone_kind=kind, foundation=found)
|
||||
for h, l3, kind, found in itertools.product(
|
||||
(1.0, 1.5, 2.5, 3.5),
|
||||
(35, 45, 55),
|
||||
("", "야면석·호박돌", "깬돌"),
|
||||
("기초유", "기초버림", ""),
|
||||
)
|
||||
] + [
|
||||
# 사용자가 정한 칸 — 기울기 · 두께 · 실무 관행 계수 · 물구멍 면적 · 잡석 두께 · 짧은 연장
|
||||
dict(height=2.0, length=7.5, back_len_cm=45, face_slope_ratio=0.35, foundation="기초유"),
|
||||
dict(height=2.0, length=10.0, back_len_cm=55, thickness_top_m=0.9, foundation="기초버림"),
|
||||
dict(height=2.0, length=10.0, back_len_cm=45, thickness_bottom_m="1.4", stone_kind="깬잡석"),
|
||||
dict(height=2.0, length=10.0, stone_kind="야면석·호박돌", stone_coeff_basis="실무 관행"),
|
||||
dict(height=1.5, length=10.0, back_len_cm=35, weep_hole_area_m2=3, foundation="기초유"),
|
||||
dict(height=2.5, length=12.0, back_len_cm=60, stone_kind="견치돌", rubble=0.3),
|
||||
# 줄이 안 서는 갈래 — `when`
|
||||
dict(height=2.0, length=10.0, blinding_concrete="안 넣음", foundation="기초유"),
|
||||
dict(height=2.0, length=10.0, stone_supply="구입", stone_kind="깬돌"),
|
||||
dict(height=2.0, length=10.0, blinding_concrete="안 넣음", stone_supply="구입"),
|
||||
dict(height=2.0, length=10.0, rubble=0.0),
|
||||
# 규격 칸 — 강도 · 물구멍 지름
|
||||
dict(height=2.0, length=10.0, fill_concrete_mpa="180", weep_hole_diameter_mm=75),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", CASES, ids=lambda c: "-".join(f"{v}" for v in c.values()))
|
||||
def test_양식_값이_전개와_같다(case: dict) -> None:
|
||||
case = dict(case)
|
||||
rubble = case.pop("rubble", None)
|
||||
height = case.pop("height")
|
||||
length = case.pop("length")
|
||||
structure = _structure(height, length, **case)
|
||||
|
||||
engine = build_table([structure], {"masonry_wet": "돌쌓기(찰)"}, None, None, rubble)
|
||||
components = engine["structures"][0]["components"]
|
||||
|
||||
template = load_template("masonry_wet")
|
||||
assert template is not None
|
||||
slope, _basis = face_slope_ratio(structure["options"], wet=True, height_m=height)
|
||||
settings = {} if rubble is None else {"rubble_base_thickness_m": rubble}
|
||||
values = template_vars(template, structure, slope, settings)
|
||||
solved = evaluate_sheets([template_sheet(template, values)])
|
||||
assert solved is not None, "Node 풀이가 안 돌았다"
|
||||
assert [row["error"] for row in solved[0]] == [None] * len(solved[0]), solved[0]
|
||||
rows = [row for row in solved[0] if not row["skipped"]]
|
||||
|
||||
assert len(rows) == len(components), (
|
||||
[r["name"] for r in rows],
|
||||
[c["name"] for c in components],
|
||||
)
|
||||
# ⚠ 알려진 차이 하나 — 「실무 관행」 계수를 고르면 전개가 돌종류까지 지워 돌 줄이 이름 「돌」·
|
||||
# 계산식 무게로 섬(야면석을 골라도). 관행은 **계수 열만** 바꾸는 칸이라 전개 쪽 결함으로 보고
|
||||
# 양식은 종류를 지킴 — 양식 `differs_from_engine` · PLAN 3장에 판정 요청으로 적음.
|
||||
practice_drops_kind = case.get("stone_coeff_basis") == "실무 관행" and case.get("stone_kind")
|
||||
for row, component in zip(rows, components):
|
||||
if row["name"] == "돌" and practice_drops_kind:
|
||||
assert (component["name"], row["spec"]) == ("돌", case["stone_kind"])
|
||||
continue
|
||||
if row["name"] == "돌":
|
||||
# 명세 13장 Ⓒ — 양식은 이름 고정 + 규격에 종류, 전개는 종류를 이름으로 씀.
|
||||
assert component["name"] == (row["spec"] or "돌")
|
||||
else:
|
||||
assert row["name"] == component["name"]
|
||||
assert row["spec"] == component["spec"], (row, component)
|
||||
assert template["rows"][row["seq"] - 1]["destination"] == component["destination"], row
|
||||
assert float(row["amount"]) == pytest.approx(component["amount"], rel=1e-9, abs=1e-12), (
|
||||
row["name"],
|
||||
row["amount"],
|
||||
component["amount"],
|
||||
)
|
||||
|
||||
|
||||
def test_실무_관측값은_대안_후보로_보인다() -> None:
|
||||
"""판정 Ⓑ — 지금 전개가 기본, 실무 관측(물구멍 2.5㎡)은 칸 옆 대안 후보."""
|
||||
hole = load_template("masonry_wet")["vars"]["HOLE_AREA"]
|
||||
assert hole["default"] == 2
|
||||
assert any(item["value"] == 2.5 for item in hole["candidates"])
|
||||
|
||||
|
||||
def test_표는_키_오름차순() -> None:
|
||||
"""근사 LOOKUP 의 전제(명세 13장) — 정확 일치만 쓰더라도 표 모양은 한 규칙으로."""
|
||||
for name, table in load_template("masonry_wet")["tables"].items():
|
||||
assert table["keys"] == sorted(table["keys"]), name
|
||||
for column, cells in table["columns"].items():
|
||||
assert len(cells) == len(table["keys"]), (name, column)
|
||||
Reference in New Issue
Block a user