feat(tester): 건설 PDF ↔ md 전수 커버리지 검사 + 겸용 연료 둘 갈라 읽기
브레인 B08·B14.
- **PDF ↔ md 커버리지**(`test_const_pdf_coverage.py`) — PDF 글자층을 좌표로 줄로 다시 묶어
값 수를 뽑고 md 전문과 양방향으로 맞댐. 지금 **결손 749**(PDF 에 있는데 md 에 없음) ·
**허구 547**(md 에만 — 대부분 붙은 줄 잔재 `0008900.90` 꼴)
· 쪽별 셈을 목록에 두어 **늘면 빨강**(새 구멍) · `--report` 로 구멍 큰 쪽 스물을 뽑음
· ⚠ 줄 짝은 안 맞춤(PDF 는 칸마다 줄바꿈) · 값처럼 생긴 수만(소수점·세 자리 이상, 연도 뺌) ·
차례 줄·쪽번호 줄은 뺌
- **겸용 연료 둘**(`_build_mach.py`) — 3450-0642 현장가열 표층재생기 `73.7+휘발유54.5` ·
7992-0001 모르타르 믹서 `1.87㎾ 휘발유1.3`
· 둘째 연료는 `secondary_fuel_*`, 전력은 `electric_power_kw`(연료가 아니라 곁값)
· 연료 줄 274 → 276 · 대조 시험도 같은 꼴을 읽게 넓혀 **기계 못 보는 자리 38 → 27**
· 이름표에 새 열 셋 + 줄 수 갱신
- 전체 시험 2,442 통과
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -254,24 +254,24 @@ def parse_fuel(
|
||||
specs = split_values(row[spec_col], len(codes)) if spec_col is not None and spec_col < len(row) else None # fmt: skip
|
||||
for n, code in enumerate(codes):
|
||||
raw = values["fuel_rate_l_per_hour"][n]
|
||||
# ⚠ 연료가 둘인 칸(`73.7+휘발유54.5`)은 한쪽만 적으면 틀림 — 안 싣고 셈만 남김
|
||||
if _num(raw) is None or "+" in raw:
|
||||
fuels, power = split_fuels(raw)
|
||||
if not fuels:
|
||||
# ⚠ 주연료가 수가 아닌 기종(전기 `㎾` · `-`)은 **연료 목록에 안 실음** — ℓ/hr 자료임.
|
||||
skipped.append({"machine_code": code, "what": raw.strip()[:20]})
|
||||
continue
|
||||
record = {
|
||||
"machine_code": code,
|
||||
"machine_name": names.get(code[:4], ""),
|
||||
# 표시 없는 것은 경유([주]① ㉰) · `휘발유0.7` · `중유487.2`
|
||||
"fuel_type": (
|
||||
"gasoline"
|
||||
if re.search(r"휘발유|가솔린", raw)
|
||||
else "heavy_oil"
|
||||
if "중유" in raw
|
||||
else "diesel"
|
||||
),
|
||||
"fuel_rate_l_per_hour": _num(raw),
|
||||
"fuel_type": fuels[0][0],
|
||||
"fuel_rate_l_per_hour": fuels[0][1],
|
||||
}
|
||||
# ⚠ **겸용 연료는 둘 다 싣는다** — 한쪽만 적으면 그만큼 경비가 빈다
|
||||
# (`73.7+휘발유54.5` 현장가열표층재생기 · `1.87㎾ 휘발유1.3` 모르타르 믹서).
|
||||
if len(fuels) > 1:
|
||||
record["secondary_fuel_type"] = fuels[1][0]
|
||||
record["secondary_fuel_rate_l_per_hour"] = fuels[1][1]
|
||||
if power is not None:
|
||||
record["electric_power_kw"] = power
|
||||
for field in ("misc_material_percent_of_fuel", "operator_person_per_day"):
|
||||
value = _num(values[field][n]) if field in values else None
|
||||
if value is not None:
|
||||
@@ -282,6 +282,36 @@ def parse_fuel(
|
||||
return records, bad, source_tables, skipped
|
||||
|
||||
|
||||
#: 겸용 연료 칸을 토막낸다 — `73.7+휘발유54.5` · `1.87㎾ 휘발유1.3` · `중유487.2`.
|
||||
#: ⚠ 표시가 없으면 경유다([주]① ㉰).
|
||||
_FUEL_PIECE_RE = re.compile(r"(휘발유|가솔린|중유|경유)?\s*([\d,]+(?:\.\d+)?)\s*(㎾|kW|kw)?")
|
||||
|
||||
|
||||
def split_fuels(raw: str) -> tuple[list[tuple[str, float]], float | None]:
|
||||
"""연료 칸 → (`[(연료갈래, ℓ/hr), …]`, 전력 ㎾).
|
||||
|
||||
한 칸에 연료가 둘인 기종이 있다 — 그 둘을 **다 싣는다**. 수가 없으면 빈 목록(전기·`-`).
|
||||
"""
|
||||
fuels: list[tuple[str, float]] = []
|
||||
power: float | None = None
|
||||
for mark, number, kw in _FUEL_PIECE_RE.findall(str(raw or "")):
|
||||
value = _num(number)
|
||||
if value is None:
|
||||
continue
|
||||
if kw: # 전력은 연료(ℓ/hr)가 아니라 곁값으로 적어 둔다
|
||||
power = value
|
||||
continue
|
||||
kind = (
|
||||
"gasoline"
|
||||
if mark in ("휘발유", "가솔린")
|
||||
else "heavy_oil"
|
||||
if mark == "중유"
|
||||
else "diesel"
|
||||
)
|
||||
fuels.append((kind, value))
|
||||
return fuels, power
|
||||
|
||||
|
||||
def extract(old_doc: dict, data: bytes) -> tuple[dict, dict]:
|
||||
"""옛 벌 + 8장 md → (새 벌, 알림). 사람 판단 칸은 옛 벌에서 이어받음."""
|
||||
lines = data.decode("utf-8").splitlines()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"schema_version": "1.0",
|
||||
"dataset_id": "mach_base",
|
||||
"effective_date": "2026-01-01",
|
||||
"generated_at": "2026-09-18T13:19:25+09:00",
|
||||
"generated_at": "2026-09-18T14:10:46+09:00",
|
||||
"sources": [
|
||||
{
|
||||
"path": "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제8장_건설기계.md",
|
||||
@@ -14132,6 +14132,17 @@
|
||||
"operator_person_per_day": 1,
|
||||
"specification": "400"
|
||||
},
|
||||
{
|
||||
"machine_code": "3450-0642",
|
||||
"machine_name": "현장가열 표층재생기",
|
||||
"fuel_type": "diesel",
|
||||
"fuel_rate_l_per_hour": 73.7,
|
||||
"secondary_fuel_type": "gasoline",
|
||||
"secondary_fuel_rate_l_per_hour": 54.5,
|
||||
"misc_material_percent_of_fuel": 20,
|
||||
"operator_person_per_day": 7,
|
||||
"specification": "479"
|
||||
},
|
||||
{
|
||||
"machine_code": "3530-0015",
|
||||
"machine_name": "스테이빌라이저(안정기)",
|
||||
@@ -15085,6 +15096,15 @@
|
||||
"operator_person_per_day": 1,
|
||||
"specification": "11.19"
|
||||
},
|
||||
{
|
||||
"machine_code": "7992-0001",
|
||||
"machine_name": "모르타르 믹서",
|
||||
"fuel_type": "gasoline",
|
||||
"fuel_rate_l_per_hour": 1.3,
|
||||
"electric_power_kw": 1.87,
|
||||
"misc_material_percent_of_fuel": 2,
|
||||
"specification": "0.3㎥"
|
||||
},
|
||||
{
|
||||
"machine_code": "7993-0020",
|
||||
"machine_name": "양수기",
|
||||
@@ -19124,6 +19144,6 @@
|
||||
"unparsed_price_rows": 0,
|
||||
"unparsed_loss_rows": 10,
|
||||
"unparsed_fuel_rows": 0,
|
||||
"fuel_not_litre_rows": 30
|
||||
"fuel_not_litre_rows": 28
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1977,7 +1977,7 @@
|
||||
"merged_columns": 178,
|
||||
"files": 41,
|
||||
"tables": 101,
|
||||
"columns": 543,
|
||||
"columns": 546,
|
||||
"value_groups": 207,
|
||||
"unknown": 0,
|
||||
"value_groups_by_kind": {
|
||||
@@ -4813,7 +4813,7 @@
|
||||
"name_ko": "기계 연료소모량·운전경비",
|
||||
"summary": "기계마다 한 시간에 쓰는 연료량과 잡재료비 비율, 운전원 수.",
|
||||
"shape": "list",
|
||||
"rows": 274,
|
||||
"rows": 276,
|
||||
"columns": [
|
||||
{
|
||||
"key": "machine_code",
|
||||
@@ -4862,6 +4862,23 @@
|
||||
"name_ko": "읽은 방법",
|
||||
"unit": "",
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"key": "secondary_fuel_type",
|
||||
"name_ko": "겸용 연료 종류",
|
||||
"unit": "",
|
||||
"note": "원문이 한 칸에 연료를 둘 적은 기종 — 현장가열 표층재생기 `73.7+휘발유54.5`."
|
||||
},
|
||||
{
|
||||
"key": "secondary_fuel_rate_l_per_hour",
|
||||
"name_ko": "겸용 시간당 연료량",
|
||||
"unit": "L"
|
||||
},
|
||||
{
|
||||
"key": "electric_power_kw",
|
||||
"name_ko": "전력",
|
||||
"unit": "㎾",
|
||||
"note": "연료(ℓ/hr)가 아니라 곁값 — 모르타르 믹서 `1.87㎾ 휘발유1.3`."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -19,11 +19,15 @@
|
||||
"owner": "랩탑 메인",
|
||||
"owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림"
|
||||
},
|
||||
"셈 연료 원문에 없는 자료 줄": {
|
||||
"source": 0,
|
||||
"data": 1,
|
||||
"why": "원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020 — 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음(원문 값 그대로) · 이 시험 읽기는 그 줄을 못 따라감"
|
||||
},
|
||||
"셈 연료 코드 수": {
|
||||
"source": 272,
|
||||
"data": 274,
|
||||
"why": "원문이 `7360-0055 0090` 꼴로 앞자리를 이어 적은 줄 하나(추출기는 읽고 이 시험 읽기는 못 따라감) + 원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020(사람이 맞춘 값을 이어받음) — 둘 다 값은 원문 그대로",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
"source": 275,
|
||||
"data": 276,
|
||||
"why": "원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020 — 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음(원문 값 그대로) · 이 시험 읽기는 그 줄을 못 따라감"
|
||||
},
|
||||
"손료 8201-0100 machine_name": {
|
||||
"source": "3D GNSS 머신 가이던스(굴착기용)",
|
||||
@@ -32,6 +36,18 @@
|
||||
"class": "㉯ 남이 고쳐야 풀림",
|
||||
"owner": "랩탑 메인",
|
||||
"owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림"
|
||||
},
|
||||
"연료 7993-0020": {
|
||||
"source": null,
|
||||
"data": {
|
||||
"machine_code": "7993-0020",
|
||||
"machine_name": "양수기",
|
||||
"fuel_type": "gasoline",
|
||||
"fuel_rate_l_per_hour": 1.3,
|
||||
"parse_method": "verified_source_row_alignment",
|
||||
"specification": "1.49㎾"
|
||||
},
|
||||
"why": "원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020 — 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음(원문 값 그대로) · 이 시험 읽기는 그 줄을 못 따라감"
|
||||
}
|
||||
},
|
||||
"material": {},
|
||||
@@ -245,26 +261,6 @@
|
||||
"owner": "랩탑 메인",
|
||||
"owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림"
|
||||
},
|
||||
"연료 못 읽은 줄 7360-0090": {
|
||||
"what": "원문 표 토막 수가 안 맞음",
|
||||
"why": "원문이 `7360-0055 0090` 꼴로 앞자리를 이어 적음 — 이 시험 읽기가 못 따라가는 자리(추출기는 읽음)",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 못 읽은 줄 7993-0020": {
|
||||
"what": "원문 표 토막 수가 안 맞음",
|
||||
"why": "8-4 양수기 줄이 `| 7993-0020 | 양수기 | 1.49㎾ | 1.49㎾ | - | - |` — 원문이 규격을 연료 칸에 밀어 적음 · 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음",
|
||||
"class": "㉯ 남이 고쳐야 풀림",
|
||||
"owner": "랩탑 메인",
|
||||
"owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림"
|
||||
},
|
||||
"연료 표 3450-0642": {
|
||||
"codes": [
|
||||
"3450-0642",
|
||||
"3450-0642"
|
||||
],
|
||||
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 5801-0045": {
|
||||
"codes": [
|
||||
"5801-0045",
|
||||
@@ -273,22 +269,6 @@
|
||||
"why": "⬜ **기계 자료 재추출 대기** — 코덱스가 8-4 운전경비 92종을 md 에 전사했으나(2026-09-18) `mach_base_2026.json` 은 그 앞 판이다. 그 벌을 만드는 추출기가 이 저장소에 없어 우리가 못 돌린다. ⇒ 고칠 자리는 마스터가 아니라 **기계 원천 벌 재추출**이다.",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 5805-0002": {
|
||||
"codes": [
|
||||
"5805-0002",
|
||||
"5805-0002"
|
||||
],
|
||||
"why": "⬜ **기계 자료 재추출 대기** — 코덱스가 8-4 운전경비 92종을 md 에 전사했으나(2026-09-18) `mach_base_2026.json` 은 그 앞 판이다. 그 벌을 만드는 추출기가 이 저장소에 없어 우리가 못 돌린다. ⇒ 고칠 자리는 마스터가 아니라 **기계 원천 벌 재추출**이다.",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 5805-0003": {
|
||||
"codes": [
|
||||
"5805-0003",
|
||||
"5805-0003"
|
||||
],
|
||||
"why": "⬜ **기계 자료 재추출 대기** — 코덱스가 8-4 운전경비 92종을 md 에 전사했으나(2026-09-18) `mach_base_2026.json` 은 그 앞 판이다. 그 벌을 만드는 추출기가 이 저장소에 없어 우리가 못 돌린다. ⇒ 고칠 자리는 마스터가 아니라 **기계 원천 벌 재추출**이다.",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 7202-0008": {
|
||||
"codes": [
|
||||
"7202-0008",
|
||||
@@ -305,54 +285,6 @@
|
||||
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 7360-0090": {
|
||||
"codes": [
|
||||
"7360-0090",
|
||||
"7360-0090"
|
||||
],
|
||||
"why": "원문이 `7360-0055 0090` 꼴로 앞자리를 이어 적음 — 이 시험 읽기가 못 따라가는 자리(추출기는 읽음)",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 7991-0050": {
|
||||
"codes": [
|
||||
"7991-0050",
|
||||
"7991-0050"
|
||||
],
|
||||
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 7991-0100": {
|
||||
"codes": [
|
||||
"7991-0100",
|
||||
"7991-0100"
|
||||
],
|
||||
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 7991-0500": {
|
||||
"codes": [
|
||||
"7991-0500",
|
||||
"7991-0500"
|
||||
],
|
||||
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 7992-0001": {
|
||||
"codes": [
|
||||
"7992-0001",
|
||||
"7992-0001"
|
||||
],
|
||||
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"연료 표 7993-0020": {
|
||||
"codes": [
|
||||
"7993-0020",
|
||||
"7993-0020"
|
||||
],
|
||||
"why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)",
|
||||
"class": "㉯ 남이 고쳐야 풀림"
|
||||
},
|
||||
"운전사 기종별 판단": {
|
||||
"what": 121,
|
||||
"why": "기종→운전사 구분(explicit_mappings)은 사람 판단 — 원문 8-1-3 5호는 기종 이름 글만 있음",
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
{
|
||||
"결손_쪽별": {
|
||||
"11": 1,
|
||||
"12": 1,
|
||||
"16": 1,
|
||||
"18": 2,
|
||||
"19": 1,
|
||||
"20": 2,
|
||||
"21": 1,
|
||||
"22": 1,
|
||||
"23": 1,
|
||||
"27": 2,
|
||||
"28": 2,
|
||||
"29": 1,
|
||||
"30": 1,
|
||||
"31": 2,
|
||||
"32": 1,
|
||||
"33": 2,
|
||||
"35": 1,
|
||||
"36": 2,
|
||||
"37": 3,
|
||||
"38": 2,
|
||||
"39": 1,
|
||||
"40": 1,
|
||||
"41": 2,
|
||||
"42": 1,
|
||||
"46": 1,
|
||||
"47": 1,
|
||||
"49": 1,
|
||||
"50": 1,
|
||||
"60": 1,
|
||||
"62": 2,
|
||||
"64": 17,
|
||||
"65": 2,
|
||||
"66": 6,
|
||||
"67": 3,
|
||||
"68": 2,
|
||||
"71": 2,
|
||||
"72": 5,
|
||||
"73": 10,
|
||||
"74": 4,
|
||||
"75": 10,
|
||||
"79": 1,
|
||||
"81": 1,
|
||||
"87": 1,
|
||||
"89": 2,
|
||||
"91": 2,
|
||||
"92": 5,
|
||||
"93": 6,
|
||||
"95": 11,
|
||||
"96": 1,
|
||||
"97": 2,
|
||||
"98": 1,
|
||||
"100": 4,
|
||||
"101": 15,
|
||||
"102": 3,
|
||||
"103": 1,
|
||||
"104": 3,
|
||||
"105": 1,
|
||||
"106": 4,
|
||||
"107": 2,
|
||||
"109": 4,
|
||||
"110": 5,
|
||||
"111": 6,
|
||||
"112": 1,
|
||||
"113": 3,
|
||||
"114": 5,
|
||||
"115": 2,
|
||||
"116": 7,
|
||||
"117": 4,
|
||||
"118": 1,
|
||||
"119": 2,
|
||||
"120": 1,
|
||||
"121": 9,
|
||||
"122": 2,
|
||||
"123": 2,
|
||||
"124": 4,
|
||||
"126": 1,
|
||||
"127": 3,
|
||||
"130": 1,
|
||||
"134": 1,
|
||||
"136": 1,
|
||||
"138": 1,
|
||||
"139": 1,
|
||||
"140": 6,
|
||||
"141": 5,
|
||||
"142": 1,
|
||||
"145": 1,
|
||||
"149": 1,
|
||||
"150": 4,
|
||||
"151": 3,
|
||||
"155": 1,
|
||||
"157": 1,
|
||||
"158": 2,
|
||||
"159": 1,
|
||||
"160": 1,
|
||||
"161": 1,
|
||||
"162": 1,
|
||||
"165": 2,
|
||||
"167": 1,
|
||||
"168": 2,
|
||||
"169": 1,
|
||||
"170": 2,
|
||||
"172": 1,
|
||||
"174": 1,
|
||||
"175": 2,
|
||||
"177": 1,
|
||||
"178": 1,
|
||||
"179": 2,
|
||||
"180": 1,
|
||||
"181": 6,
|
||||
"182": 4,
|
||||
"183": 2,
|
||||
"185": 1,
|
||||
"187": 1,
|
||||
"188": 2,
|
||||
"190": 2,
|
||||
"192": 3,
|
||||
"193": 2,
|
||||
"195": 1,
|
||||
"196": 1,
|
||||
"197": 1,
|
||||
"198": 1,
|
||||
"204": 5,
|
||||
"205": 1,
|
||||
"207": 1,
|
||||
"208": 2,
|
||||
"211": 1,
|
||||
"213": 1,
|
||||
"218": 1,
|
||||
"220": 1,
|
||||
"221": 1,
|
||||
"223": 1,
|
||||
"224": 1,
|
||||
"225": 3,
|
||||
"226": 2,
|
||||
"227": 1,
|
||||
"228": 3,
|
||||
"229": 1,
|
||||
"230": 1,
|
||||
"231": 3,
|
||||
"233": 1,
|
||||
"235": 1,
|
||||
"236": 2,
|
||||
"238": 1,
|
||||
"239": 1,
|
||||
"241": 1,
|
||||
"242": 4,
|
||||
"243": 2,
|
||||
"244": 9,
|
||||
"245": 3,
|
||||
"246": 4,
|
||||
"248": 1,
|
||||
"249": 2,
|
||||
"253": 2,
|
||||
"254": 2,
|
||||
"257": 2,
|
||||
"262": 1,
|
||||
"263": 4,
|
||||
"264": 1,
|
||||
"265": 4,
|
||||
"267": 1,
|
||||
"268": 3,
|
||||
"270": 1,
|
||||
"271": 2,
|
||||
"274": 1,
|
||||
"275": 26,
|
||||
"276": 2,
|
||||
"277": 1,
|
||||
"282": 2,
|
||||
"284": 2,
|
||||
"285": 14,
|
||||
"286": 10,
|
||||
"287": 33,
|
||||
"288": 2,
|
||||
"289": 6,
|
||||
"290": 8,
|
||||
"291": 4,
|
||||
"292": 4,
|
||||
"293": 3,
|
||||
"294": 1,
|
||||
"295": 6,
|
||||
"297": 6,
|
||||
"298": 14,
|
||||
"299": 1,
|
||||
"300": 2,
|
||||
"301": 1,
|
||||
"302": 1,
|
||||
"308": 2,
|
||||
"310": 7,
|
||||
"311": 1,
|
||||
"313": 1,
|
||||
"315": 1,
|
||||
"316": 1,
|
||||
"317": 1,
|
||||
"323": 2,
|
||||
"324": 1,
|
||||
"326": 12,
|
||||
"327": 21,
|
||||
"328": 5,
|
||||
"331": 2,
|
||||
"334": 1,
|
||||
"335": 2,
|
||||
"336": 1,
|
||||
"338": 1,
|
||||
"342": 3,
|
||||
"353": 1,
|
||||
"356": 1,
|
||||
"357": 1,
|
||||
"374": 1,
|
||||
"377": 3,
|
||||
"381": 1,
|
||||
"382": 1,
|
||||
"389": 1,
|
||||
"405": 1,
|
||||
"413": 11,
|
||||
"421": 1,
|
||||
"424": 4,
|
||||
"427": 1,
|
||||
"429": 2,
|
||||
"431": 1,
|
||||
"432": 1,
|
||||
"433": 1,
|
||||
"435": 1,
|
||||
"436": 1,
|
||||
"439": 1,
|
||||
"441": 1,
|
||||
"452": 2,
|
||||
"487": 4,
|
||||
"508": 1,
|
||||
"533": 4,
|
||||
"541": 3,
|
||||
"542": 1,
|
||||
"545": 1,
|
||||
"579": 1,
|
||||
"601": 1,
|
||||
"625": 1,
|
||||
"632": 1,
|
||||
"663": 1,
|
||||
"683": 1,
|
||||
"701": 1,
|
||||
"704": 2,
|
||||
"718": 1,
|
||||
"723": 1,
|
||||
"733": 3,
|
||||
"750": 1,
|
||||
"770": 1,
|
||||
"779": 1,
|
||||
"780": 1,
|
||||
"781": 1,
|
||||
"794": 1,
|
||||
"796": 1,
|
||||
"806": 1,
|
||||
"820": 1,
|
||||
"822": 2,
|
||||
"824": 2,
|
||||
"832": 1,
|
||||
"833": 1,
|
||||
"834": 4,
|
||||
"839": 1,
|
||||
"840": 1,
|
||||
"843": 11,
|
||||
"852": 1,
|
||||
"863": 1,
|
||||
"866": 1,
|
||||
"868": 1,
|
||||
"877": 1,
|
||||
"879": 1,
|
||||
"880": 1,
|
||||
"882": 1,
|
||||
"883": 1,
|
||||
"884": 1,
|
||||
"885": 1,
|
||||
"886": 1,
|
||||
"887": 1,
|
||||
"892": 1,
|
||||
"897": 1,
|
||||
"898": 1,
|
||||
"899": 1,
|
||||
"900": 1,
|
||||
"905": 1,
|
||||
"908": 1,
|
||||
"915": 1,
|
||||
"920": 1,
|
||||
"921": 1,
|
||||
"925": 1,
|
||||
"931": 1,
|
||||
"937": 1,
|
||||
"940": 1,
|
||||
"941": 1,
|
||||
"943": 1,
|
||||
"945": 1,
|
||||
"947": 1,
|
||||
"949": 1,
|
||||
"950": 1,
|
||||
"953": 1,
|
||||
"959": 1,
|
||||
"960": 1,
|
||||
"961": 1,
|
||||
"964": 1,
|
||||
"969": 1,
|
||||
"973": 1,
|
||||
"980": 1
|
||||
},
|
||||
"결손_총": 749,
|
||||
"허구_총": 547,
|
||||
"허구_값": {
|
||||
"0.0020": 1,
|
||||
"0.0040": 1,
|
||||
"0.0080": 1,
|
||||
"0.00840": 1,
|
||||
"0.010": 3,
|
||||
"0.01070": 1,
|
||||
"0.020": 2,
|
||||
"0.04": 1,
|
||||
"0.0460": 1,
|
||||
"0.050": 5,
|
||||
"0.0508": 1,
|
||||
"0.0690": 1,
|
||||
"0.070": 1,
|
||||
"0.071": 1,
|
||||
"0.07530": 1,
|
||||
"0.099": 1,
|
||||
"0.100": 2,
|
||||
"0.110": 1,
|
||||
"0.125": 1,
|
||||
"0.1520": 1,
|
||||
"0.300": 1,
|
||||
"0.310": 1,
|
||||
"0.312": 1,
|
||||
"0.335": 7,
|
||||
"0.413": 1,
|
||||
"0.432": 1,
|
||||
"0.4371": 1,
|
||||
"0.484": 1,
|
||||
"0.493": 1,
|
||||
"0.51": 1,
|
||||
"0.55": 2,
|
||||
"0.60": 2,
|
||||
"0.63": 1,
|
||||
"0.649": 1,
|
||||
"0.679": 1,
|
||||
"0.70": 3,
|
||||
"0.80": 2,
|
||||
"0.8112": 1,
|
||||
"0.814": 1,
|
||||
"0.82": 1,
|
||||
"0.855": 1,
|
||||
"0.909": 1,
|
||||
"0.92": 1,
|
||||
"00.91": 1,
|
||||
"000.90": 2,
|
||||
"00017800.90": 4,
|
||||
"00020.23": 1,
|
||||
"00028337686601": 1,
|
||||
"0002833768660100040.43": 1,
|
||||
"0002833768660100060.63": 1,
|
||||
"0002833768660100070.73": 1,
|
||||
"0002833768660100080.83": 1,
|
||||
"0002833768660100101.03": 1,
|
||||
"0008900.90": 13,
|
||||
"001020": 1,
|
||||
"00223.0016420": 1,
|
||||
"00818.16": 1,
|
||||
"01.0": 1,
|
||||
"01.01": 2,
|
||||
"01.5": 1,
|
||||
"010.01": 1,
|
||||
"010.010": 1,
|
||||
"010.02": 2,
|
||||
"010.05": 1,
|
||||
"010274.68": 1,
|
||||
"0140.010": 1,
|
||||
"02.2": 1,
|
||||
"020.02": 1,
|
||||
"020.020": 6,
|
||||
"020.10": 1,
|
||||
"022016270008900.90": 1,
|
||||
"0241500.2260": 1,
|
||||
"0327": 1,
|
||||
"0332000.3160": 1,
|
||||
"03730.174": 1,
|
||||
"042": 1,
|
||||
"043.1": 1,
|
||||
"04532": 1,
|
||||
"050.010": 1,
|
||||
"050.020": 1,
|
||||
"050.05": 4,
|
||||
"050.050": 9,
|
||||
"050.300": 1,
|
||||
"0561.1203": 1,
|
||||
"060650.0730": 1,
|
||||
"070.07": 1,
|
||||
"070.070": 1,
|
||||
"070.10": 1,
|
||||
"07467.465": 1,
|
||||
"075800.1000": 1,
|
||||
"08.8": 1,
|
||||
"080": 1,
|
||||
"1.01": 1,
|
||||
"1.02": 1,
|
||||
"1.10431": 1,
|
||||
"1.129": 1,
|
||||
"1.15": 1,
|
||||
"1.150490": 1,
|
||||
"1.17": 1,
|
||||
"1.209": 1,
|
||||
"1.24": 1,
|
||||
"1.308": 1,
|
||||
"1.31": 1,
|
||||
"1.33": 2,
|
||||
"1.369": 1,
|
||||
"1.39": 1,
|
||||
"1.44": 1,
|
||||
"1.56": 1,
|
||||
"1.629": 1,
|
||||
"1.75": 2,
|
||||
"1.8110": 1,
|
||||
"1.85": 1,
|
||||
"10.0": 1,
|
||||
"10.100": 1,
|
||||
"100": 11,
|
||||
"100.020": 1,
|
||||
"100.05": 1,
|
||||
"100.070": 1,
|
||||
"100.100": 1,
|
||||
"100.20": 1,
|
||||
"100.50": 1,
|
||||
"1000.1360": 1,
|
||||
"10001": 3,
|
||||
"10003.533331": 1,
|
||||
"10004.54443": 1,
|
||||
"1001096": 1,
|
||||
"1007": 1,
|
||||
"105": 1,
|
||||
"107": 1,
|
||||
"11.52": 1,
|
||||
"110": 3,
|
||||
"115": 1,
|
||||
"120": 1,
|
||||
"120032.52": 1,
|
||||
"120043.5333300": 1,
|
||||
"12211611110610296": 1,
|
||||
"125": 1,
|
||||
"1250.1810": 1,
|
||||
"1255006742299": 1,
|
||||
"12550067422990202": 1,
|
||||
"12550067422990204": 1,
|
||||
"12550067422990302": 1,
|
||||
"12550067422990402": 1,
|
||||
"126.1145": 1,
|
||||
"128": 1,
|
||||
"134.940": 1,
|
||||
"1380": 1,
|
||||
"1400012500.90": 1,
|
||||
"15.0": 1,
|
||||
"150": 2,
|
||||
"150.075": 1,
|
||||
"1504": 1,
|
||||
"1521220520019318718217520176171165160154148": 1,
|
||||
"156": 1,
|
||||
"1560035": 1,
|
||||
"15600506.0037320": 1,
|
||||
"15600727.5053720": 1,
|
||||
"156016012.501": 1,
|
||||
"156018016.001": 1,
|
||||
"156020025.001": 1,
|
||||
"160.48": 1,
|
||||
"1600": 2,
|
||||
"1610.3181": 1,
|
||||
"166": 2,
|
||||
"169": 1,
|
||||
"17.18": 1,
|
||||
"1705": 1,
|
||||
"171": 1,
|
||||
"1722": 1,
|
||||
"173": 2,
|
||||
"176": 1,
|
||||
"18.5": 1,
|
||||
"18002000220024002600280030000": 1,
|
||||
"180020002200240026002800300010219": 1,
|
||||
"180174169163157152": 1,
|
||||
"186.58": 1,
|
||||
"1942000017800.90": 1,
|
||||
"2.0411": 1,
|
||||
"2.05": 1,
|
||||
"2.3112": 1,
|
||||
"2.40": 1,
|
||||
"2.45": 1,
|
||||
"2.86": 1,
|
||||
"2.96": 1,
|
||||
"20.950": 1,
|
||||
"200": 8,
|
||||
"200.050": 1,
|
||||
"200.100": 1,
|
||||
"200.200": 1,
|
||||
"20145139135130124118114": 1,
|
||||
"20180175170165160155150": 1,
|
||||
"203": 1,
|
||||
"203197190": 1,
|
||||
"21.101": 1,
|
||||
"21.87": 1,
|
||||
"21.98": 1,
|
||||
"210": 1,
|
||||
"2100": 1,
|
||||
"214209": 1,
|
||||
"2168.4191": 1,
|
||||
"22222233333": 1,
|
||||
"224.08": 1,
|
||||
"2300": 1,
|
||||
"232": 2,
|
||||
"240": 1,
|
||||
"250": 5,
|
||||
"250.028": 1,
|
||||
"2500": 1,
|
||||
"250001": 1,
|
||||
"25001": 1,
|
||||
"250300350400450500": 1,
|
||||
"2510": 2,
|
||||
"26.330": 1,
|
||||
"260": 1,
|
||||
"261": 1,
|
||||
"271.0": 1,
|
||||
"271.2": 1,
|
||||
"2867146822682": 1,
|
||||
"290": 1,
|
||||
"299.98": 1,
|
||||
"3.01": 1,
|
||||
"3.06": 1,
|
||||
"3.1014": 1,
|
||||
"3.21": 1,
|
||||
"3.2516": 1,
|
||||
"3.27": 1,
|
||||
"3.40": 1,
|
||||
"3.61": 1,
|
||||
"3.67": 1,
|
||||
"3.81": 1,
|
||||
"300": 5,
|
||||
"300.30": 1,
|
||||
"300.300": 1,
|
||||
"300523": 1,
|
||||
"3012111611110610195904066": 1,
|
||||
"3015515114614113613212640128124119113109104": 1,
|
||||
"31.101": 1,
|
||||
"320": 1,
|
||||
"320034003600380040004200440010": 1,
|
||||
"325.531": 1,
|
||||
"3432000017800.90": 1,
|
||||
"3473413342328322315309303296290": 1,
|
||||
"350.6": 1,
|
||||
"3507": 2,
|
||||
"360": 1,
|
||||
"361355": 1,
|
||||
"377370": 1,
|
||||
"382.6": 1,
|
||||
"386.3": 1,
|
||||
"4.01": 1,
|
||||
"40.11": 5,
|
||||
"400": 7,
|
||||
"400500600700": 1,
|
||||
"4095908681": 1,
|
||||
"413": 1,
|
||||
"419": 2,
|
||||
"423": 1,
|
||||
"43.049": 1,
|
||||
"437.344": 1,
|
||||
"440": 1,
|
||||
"4450": 1,
|
||||
"447": 2,
|
||||
"4545": 1,
|
||||
"461": 2,
|
||||
"47.89": 1,
|
||||
"471121": 1,
|
||||
"474236": 1,
|
||||
"4912000017800.90": 1,
|
||||
"5.0026120": 1,
|
||||
"5.01": 1,
|
||||
"5.02": 1,
|
||||
"5.36": 1,
|
||||
"5.7118": 1,
|
||||
"50.11": 3,
|
||||
"50.257": 1,
|
||||
"500.056": 1,
|
||||
"500.300": 1,
|
||||
"50001": 1,
|
||||
"5008.57": 1,
|
||||
"5008336933026": 1,
|
||||
"501": 2,
|
||||
"5010197": 1,
|
||||
"51.72": 1,
|
||||
"51100": 1,
|
||||
"5149.2": 1,
|
||||
"5150": 1,
|
||||
"52.52": 1,
|
||||
"5207.7": 1,
|
||||
"525": 2,
|
||||
"528027526826225525024410248242235230223218": 1,
|
||||
"550": 1,
|
||||
"5500": 1,
|
||||
"555": 1,
|
||||
"5600": 1,
|
||||
"576.56": 1,
|
||||
"6.01": 1,
|
||||
"6.02": 1,
|
||||
"6.0722": 1,
|
||||
"6.52": 1,
|
||||
"6.62": 1,
|
||||
"600": 6,
|
||||
"612.715": 1,
|
||||
"615": 1,
|
||||
"615751": 1,
|
||||
"6363636363636363636363": 1,
|
||||
"637": 1,
|
||||
"6500": 1,
|
||||
"657.665": 1,
|
||||
"670": 1,
|
||||
"6790": 1,
|
||||
"7.12": 1,
|
||||
"7.22": 1,
|
||||
"7.4": 1,
|
||||
"7.522": 1,
|
||||
"7.62": 1,
|
||||
"7.8": 1,
|
||||
"7.93": 1,
|
||||
"7.96": 1,
|
||||
"70.16435004721": 1,
|
||||
"700": 3,
|
||||
"70054.5444": 1,
|
||||
"7006.565": 1,
|
||||
"7065": 1,
|
||||
"710121519283233": 1,
|
||||
"750.14503753311": 7,
|
||||
"761": 1,
|
||||
"76413": 1,
|
||||
"76813": 1,
|
||||
"77413": 1,
|
||||
"77613": 1,
|
||||
"77713": 1,
|
||||
"78.36": 1,
|
||||
"78313": 1,
|
||||
"78413": 1,
|
||||
"78713": 1,
|
||||
"78913": 1,
|
||||
"79613": 1,
|
||||
"7975": 1,
|
||||
"8.43": 1,
|
||||
"80.15": 1,
|
||||
"800": 3,
|
||||
"8001000708": 1,
|
||||
"8052": 1,
|
||||
"80713": 1,
|
||||
"81.0": 1,
|
||||
"81.02": 1,
|
||||
"81013": 1,
|
||||
"81213": 1,
|
||||
"82113": 1,
|
||||
"82313": 1,
|
||||
"82413": 1,
|
||||
"82513": 1,
|
||||
"82613": 1,
|
||||
"82713": 1,
|
||||
"82813": 1,
|
||||
"82913": 1,
|
||||
"83013": 1,
|
||||
"83113": 1,
|
||||
"83413": 1,
|
||||
"83613": 1,
|
||||
"838.148": 1,
|
||||
"84013": 1,
|
||||
"84113": 1,
|
||||
"84213": 1,
|
||||
"84313": 1,
|
||||
"84413": 1,
|
||||
"84913": 1,
|
||||
"850.13": 6,
|
||||
"85013": 1,
|
||||
"85213": 1,
|
||||
"8581": 1,
|
||||
"8591": 1,
|
||||
"8631": 1,
|
||||
"8641": 1,
|
||||
"8651": 1,
|
||||
"8691": 1,
|
||||
"87.17": 1,
|
||||
"874.689": 1,
|
||||
"8752": 1,
|
||||
"8802": 1,
|
||||
"8812": 1,
|
||||
"8842": 1,
|
||||
"8852": 1,
|
||||
"8872": 1,
|
||||
"8892": 1,
|
||||
"8912": 1,
|
||||
"8932": 1,
|
||||
"8942": 1,
|
||||
"8962": 1,
|
||||
"8972": 1,
|
||||
"9.0": 1,
|
||||
"9.23": 1,
|
||||
"9.8": 1,
|
||||
"90.951": 1,
|
||||
"900": 2,
|
||||
"900.80": 1,
|
||||
"9012": 1,
|
||||
"9032": 1,
|
||||
"9042": 1,
|
||||
"9052": 1,
|
||||
"9060": 5,
|
||||
"9072": 1,
|
||||
"9082": 1,
|
||||
"9133": 1,
|
||||
"916": 1,
|
||||
"9173": 1,
|
||||
"9183": 1,
|
||||
"9214": 1,
|
||||
"9224": 1,
|
||||
"9244": 1,
|
||||
"938983": 1,
|
||||
"951.01": 1,
|
||||
"967.176": 1,
|
||||
"982": 1
|
||||
}
|
||||
}
|
||||
@@ -187,18 +187,31 @@ def _codes(cell: str, prefix: str | None) -> tuple[list[str] | None, str | None]
|
||||
|
||||
LOSS_COLS = {"economic_life_hours": "내용시간", "annual_standard_hours": "가동시간", "depreciation_ratio": "상각비율", "maintenance_ratio": "정비비율", "annual_management_ratio": "관리비율"} # fmt: skip
|
||||
LOSS_COEF = ("depreciation_coefficient_1e_minus_7", "maintenance_coefficient_1e_minus_7", "management_coefficient_1e_minus_7", "source_coefficient_1e_minus_7") # fmt: skip
|
||||
FUEL_FIELDS = ("fuel_type", "fuel_rate_l_per_hour", "misc_material_percent_of_fuel", "operator_person_per_day") # fmt: skip
|
||||
FUEL_FIELDS = ("fuel_type", "fuel_rate_l_per_hour", "misc_material_percent_of_fuel", "operator_person_per_day", "secondary_fuel_type", "secondary_fuel_rate_l_per_hour", "electric_power_kw") # fmt: skip
|
||||
|
||||
|
||||
#: 연료 토막 — `9.0` 경유([주]① ㉰) · `휘발유0.7` · `중유487.2` · 겸용 `73.7+휘발유54.5` ·
|
||||
#: 전력과 겸용 `1.87㎾ 휘발유1.3`. ⚠ 꼴이 다르면 ValueError — 지어 읽지 않는다.
|
||||
_FUEL_TOKEN_RE = re.compile(r"(휘발유|중유|경유)?(\d+(?:\.\d+)?)(㎾)?")
|
||||
|
||||
|
||||
def _fuel(tok: str) -> tuple:
|
||||
"""주연료 토막 — `9.0` 경유([주]① ㉰: 표시 없는 것은 경유) · `휘발유0.7` · `중유487.2` · `-` 없음."""
|
||||
"""`(갈래, ℓ/hr, 겸용 갈래, 겸용 ℓ/hr, 전력㎾)`. 값이 없으면 자리마다 `None`."""
|
||||
if tok == "-":
|
||||
return None, None
|
||||
m = re.fullmatch(r"(휘발유|중유)?(\d+(?:\.\d+)?)", tok)
|
||||
if m is None:
|
||||
return None, None, None, None, None
|
||||
rest, fuels, power = tok, [], None
|
||||
for m in _FUEL_TOKEN_RE.finditer(tok):
|
||||
rest = rest.replace(m.group(0), "", 1)
|
||||
if m.group(3): # 전력은 ℓ/hr 가 아니다 — 곁값
|
||||
power = float(m.group(2))
|
||||
continue
|
||||
kind = {"휘발유": "gasoline", "중유": "heavy_oil", "경유": "diesel", None: "diesel"}[m.group(1)] # fmt: skip
|
||||
fuels.append((kind, float(m.group(2))))
|
||||
if rest.replace("+", "").strip() or not (fuels or power):
|
||||
raise ValueError(tok)
|
||||
kind = {"휘발유": "gasoline", "중유": "heavy_oil", None: "diesel"}[m.group(1)]
|
||||
return kind, float(m.group(2))
|
||||
first = fuels[0] if fuels else (None, None)
|
||||
second = fuels[1] if len(fuels) > 1 else (None, None)
|
||||
return first[0], first[1], second[0], second[1], power
|
||||
|
||||
|
||||
def read_machine(path: Path) -> dict:
|
||||
@@ -268,11 +281,16 @@ def read_machine(path: Path) -> dict:
|
||||
continue
|
||||
for r in t["rows"]:
|
||||
codes, prefix = _codes(r[0], prefix)
|
||||
cols = [r[3].split(), r[4].split(), r[5].split()]
|
||||
# ⚠ 코드가 하나인 줄은 **칸 통째로** 한 값이다 — `1.87㎾ 휘발유1.3` 처럼 값 안에 공백이 온다.
|
||||
cols = [[c.strip()] if len(codes) == 1 else c.split() for c in (r[3], r[4], r[5])]
|
||||
try:
|
||||
if not codes or any(len(c) != len(codes) for c in cols):
|
||||
raise ValueError(r[0])
|
||||
parsed = [(*_fuel(f), _num(m), _num(o)) for f, m, o in zip(*cols)]
|
||||
parsed = [
|
||||
(ft[0], ft[1], _num(m), _num(o), ft[2], ft[3], ft[4])
|
||||
for f, m, o in zip(*cols)
|
||||
for ft in (_fuel(f),)
|
||||
]
|
||||
except ValueError:
|
||||
out["skipped"][f"연료 표 {codes[0] if codes else _t(r[0])[:24]}"] = {
|
||||
"codes": codes and [codes[0], codes[-1]]
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
"""건설 원문 PDF ↔ md **전수 커버리지** — 2026-09-18 브레인 B14(사용자 지시).
|
||||
|
||||
왜 — 지금껏 증상이 잡히는 대로 하나씩 찾아 고쳐 느렸다. **구멍 전체 크기를 한 번에** 드러내
|
||||
목록으로 몰아 고치려고 둔 검사다.
|
||||
|
||||
무엇을 재나 — PDF 글자층에서 좌표로 줄을 다시 묶어 **값처럼 생긴 수**를 뽑고, md 표의 수와
|
||||
양방향으로 맞댄다.
|
||||
|
||||
```
|
||||
결손 PDF 에 있는데 md 에 없음 ← 9020 해상기계처럼 표째 빠진 병
|
||||
허구 md 에 있는데 PDF 에 없음 ← 잘못 지어 넣은 값
|
||||
```
|
||||
|
||||
⚠ **줄 짝은 안 맞춘다** — PDF 는 칸마다 줄을 바꾸고 md 는 칸을 묶어 적어, 줄끼리 맞대면
|
||||
멀쩡한 표도 어긋난 것으로 뜬다. **수의 갯수(중복 포함)** 로만 맞댄다.
|
||||
⚠ **값처럼 생긴 수만 본다** — 소수점이 있거나 세 자리 이상. 쪽번호·차례 번호·항목 번호를
|
||||
값으로 세면 셈이 노이즈에 묻힌다.
|
||||
⚠ 차례(`···· 123`) 줄은 뺀다 — 쪽 번호가 값으로 섞인다.
|
||||
|
||||
지금 남은 것은 `fixtures/const_pdf_coverage_known.json` 에 **쪽별 셈**으로 적어 둔다.
|
||||
줄이면 초록, 늘면 빨강 — 새 구멍이 생겼다는 뜻이다.
|
||||
|
||||
목록 갱신: `./venv/Scripts/python.exe resources/tester/test_const_pdf_coverage.py --prune`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PDF = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈.pdf"
|
||||
MD = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈.md"
|
||||
KNOWN = Path(__file__).with_name("fixtures") / "const_pdf_coverage_known.json"
|
||||
|
||||
_NUM = re.compile(r"\d[\d,]*(?:\.\d+)?")
|
||||
#: 차례 줄 — 점선 뒤 쪽번호가 값으로 섞인다.
|
||||
_LEADER = re.compile(r"[·․…\.]{4,}")
|
||||
#: 쪽번호 줄 — 수 하나만 있고 다른 글자가 없다.
|
||||
_ONLY_NUMBER = re.compile(r"\d[\d,]*(?:\.\d+)?")
|
||||
#: 한 줄로 묶을 때 세로 오차(포인트).
|
||||
_ROW_TOLERANCE = 2.5
|
||||
|
||||
|
||||
def _norm(token: str) -> str:
|
||||
return token.replace(",", "")
|
||||
|
||||
|
||||
def _valuish(token: str) -> bool:
|
||||
"""값처럼 생긴 수인가 — 소수점이 있거나 세 자리 이상.
|
||||
|
||||
⚠ 연도(`2026`·`1999`)는 뺀다 — 「'22년 보완」·발행연도가 값으로 섞인다.
|
||||
"""
|
||||
if re.fullmatch(r"(?:19|20)\d\d", token):
|
||||
return False
|
||||
return "." in token or len(token) >= 3
|
||||
|
||||
|
||||
def _page_rows(page) -> list[str]:
|
||||
"""글자 좌표로 **줄을 다시 묶는다** — PDF 글자층은 칸마다 줄을 바꾼다."""
|
||||
words = sorted(page.get_text("words"), key=lambda w: (round(w[1] / _ROW_TOLERANCE), w[0]))
|
||||
rows: list[list] = []
|
||||
current: list = []
|
||||
top: float | None = None
|
||||
for word in words:
|
||||
if top is None or abs(word[1] - top) <= _ROW_TOLERANCE:
|
||||
current.append(word)
|
||||
top = word[1] if top is None else top
|
||||
else:
|
||||
rows.append(current)
|
||||
current, top = [word], word[1]
|
||||
if current:
|
||||
rows.append(current)
|
||||
return [" ".join(w[4] for w in sorted(row, key=lambda x: x[0])) for row in rows]
|
||||
|
||||
|
||||
def pdf_numbers() -> tuple[collections.Counter, dict[str, list[int]]]:
|
||||
"""PDF 값 수의 셈과 **어느 쪽에서 나왔는지**."""
|
||||
import pymupdf
|
||||
|
||||
counts: collections.Counter = collections.Counter()
|
||||
pages: dict[str, list[int]] = collections.defaultdict(list)
|
||||
with pymupdf.open(PDF) as doc:
|
||||
for number, page in enumerate(doc, start=1):
|
||||
for line in _page_rows(page):
|
||||
if _LEADER.search(line):
|
||||
continue
|
||||
found = [_norm(x) for x in _NUM.findall(line) if _valuish(_norm(x))]
|
||||
# ⚠ **쪽번호 줄만 뺀다** — 줄에 수 하나뿐이고 다른 글자가 없는 줄이다.
|
||||
# 값 하나짜리 줄까지 빼면 여러 줄로 갈린 칸(`[80∼95]`)이 통째로 안 세어진다.
|
||||
if not found or _ONLY_NUMBER.fullmatch(line.strip()):
|
||||
continue
|
||||
for token in found:
|
||||
counts[token] += 1
|
||||
pages[token].append(number)
|
||||
return counts, pages
|
||||
|
||||
|
||||
def md_numbers() -> collections.Counter:
|
||||
"""md 전문의 값 수 — ⚠ **표만 보지 않는다.** md 는 PDF 를 옮겨 적은 글이라 본문·[주]까지
|
||||
맞대야 「옮겨 적다 빠뜨린 것」이 드러난다(표만 보면 PDF 본문 쪽이 통째로 결손으로 뜬다)."""
|
||||
counts: collections.Counter = collections.Counter()
|
||||
for line in MD.read_text(encoding="utf-8").splitlines():
|
||||
if _LEADER.search(line):
|
||||
continue
|
||||
found = [_norm(x) for x in _NUM.findall(line) if _valuish(_norm(x))]
|
||||
if not found or _ONLY_NUMBER.fullmatch(line.strip()):
|
||||
continue
|
||||
counts.update(found)
|
||||
return counts
|
||||
|
||||
|
||||
def measure() -> dict[str, object]:
|
||||
"""`{결손 쪽별 셈, 허구 수, 총계}` — 양방향 구멍."""
|
||||
pdf, pages = pdf_numbers()
|
||||
md = md_numbers()
|
||||
missing, invented = pdf - md, md - pdf
|
||||
per_page: collections.Counter = collections.Counter()
|
||||
for token, count in missing.items():
|
||||
for page in pages[token][:count]:
|
||||
per_page[page] += 1
|
||||
return {
|
||||
"결손_쪽별": {str(k): v for k, v in sorted(per_page.items())},
|
||||
"결손_총": sum(missing.values()),
|
||||
"허구_총": sum(invented.values()),
|
||||
"허구_값": {k: v for k, v in sorted(invented.items())},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def now() -> dict[str, object]:
|
||||
return measure()
|
||||
|
||||
|
||||
def _known() -> dict:
|
||||
return json.loads(KNOWN.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_결손이_목록보다_늘지_않음(now: dict) -> None:
|
||||
"""PDF 에 있는데 md 에 없는 값 — **줄면 초록, 늘면 빨강**(새 구멍)."""
|
||||
known = _known()
|
||||
grew = [
|
||||
f" 쪽 {page}: 목록 {known['결손_쪽별'].get(page, 0)} → 지금 {count}"
|
||||
for page, count in now["결손_쪽별"].items()
|
||||
if count > known["결손_쪽별"].get(page, 0)
|
||||
]
|
||||
assert not grew, "PDF 에만 있는 값이 늘었음 — md 에서 빠진 자리:\n" + "\n".join(grew)
|
||||
|
||||
|
||||
def test_허구가_목록보다_늘지_않음(now: dict) -> None:
|
||||
"""md 에 있는데 PDF 에 없는 값 — 지어 넣은 자리."""
|
||||
known = _known()
|
||||
grew = {
|
||||
token: count
|
||||
for token, count in now["허구_값"].items()
|
||||
if count > known["허구_값"].get(token, 0)
|
||||
}
|
||||
assert not grew, f"PDF 에 없는 값이 md 에 늘었음: {grew}"
|
||||
|
||||
|
||||
def test_목록이_원문과_같은_판(now: dict) -> None:
|
||||
"""목록의 총계가 지금 셈과 같은가 — 줄었으면 `--prune` 으로 목록을 줄일 것."""
|
||||
known = _known()
|
||||
assert now["결손_총"] <= known["결손_총"], (
|
||||
f"결손 {known['결손_총']} → {now['결손_총']}" # 늘었다
|
||||
)
|
||||
assert now["허구_총"] <= known["허구_총"], f"허구 {known['허구_총']} → {now['허구_총']}"
|
||||
|
||||
|
||||
if __name__ == "__main__" and "--report" in sys.argv:
|
||||
# 구멍 목록 — md 를 고치는 창(코덱스·랩탑서브·안티그래비티)이 볼 자리
|
||||
pdf_counts, pdf_pages = pdf_numbers()
|
||||
md_counts = md_numbers()
|
||||
missing, invented = pdf_counts - md_counts, md_counts - pdf_counts
|
||||
holes: collections.Counter = collections.Counter()
|
||||
for value, count in missing.items():
|
||||
for page in pdf_pages[value][:count]:
|
||||
holes[page] += 1
|
||||
print(f"결손 {sum(missing.values())} · 허구 {sum(invented.values())}")
|
||||
print("구멍 큰 쪽 스물")
|
||||
for page, count in holes.most_common(20):
|
||||
print(f" 쪽 {page:4d} {count}")
|
||||
print("PDF 에 없는 값 열(붙은 줄 잔재가 많다)")
|
||||
for value, count in invented.most_common(10):
|
||||
print(f" {value} x{count}")
|
||||
|
||||
if __name__ == "__main__" and "--prune" in sys.argv:
|
||||
got = measure()
|
||||
before = _known()
|
||||
KNOWN.write_text(json.dumps(got, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||||
print(
|
||||
f"결손 {before['결손_총']} → {got['결손_총']} · 허구 {before['허구_총']} → {got['허구_총']}"
|
||||
)
|
||||
Reference in New Issue
Block a user