You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
hy-screen-2.0/scripts/generate-family-pasture-dat...

390 lines
15 KiB

#!/usr/bin/env python3
"""Build the family ecological pasture dataset used by the Yak Industry Chain screen.
The historical construction ledger is the authoritative source. The 2025 owner
sheet only enriches matched 2025 ledger rows with map coordinates; it never
changes the construction-record count.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import tempfile
from collections import defaultdict
from pathlib import Path
import openpyxl
SCRIPT_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SUMMARY = Path(
"/Users/mocker/Documents/hy/数据清单 2/家庭牧场/红原县2023年至今建设家庭牧场汇总表.xls"
)
DEFAULT_POINTS = Path(
"/Users/mocker/Documents/hy/数据清单 2/家庭牧场/红原县2025年适度规模化(生态家庭牧场)家庭牧场项目-户主信息表.xlsx"
)
DEFAULT_OUTPUT = SCRIPT_ROOT / "src/views/yakIndustryChain/familyPastureData.json"
def clean(value: object) -> str:
return "" if value is None else str(value).strip()
def key_text(value: object) -> str:
return re.sub(r"\s+", "", clean(value))
def number(value: object) -> int | float | None:
if value is None or value == "":
return None
try:
value = float(value)
except (TypeError, ValueError):
return None
return int(value) if value.is_integer() else value
def extract_area(text: str, patterns: list[str]) -> int | float | None:
for pattern in patterns:
matched = re.search(pattern, text, re.IGNORECASE)
if matched:
return number(matched.group(1))
return None
def parse_facilities(text: str) -> dict:
normalized = re.sub(r"\s+", "", text or "")
greenhouse = extract_area(
normalized,
[
r"(?:标准化|标准分类)?(?:养殖)?暖棚(?:达到)?(\d+(?:\.\d+)?)(?:㎡|平方米)",
r"暖棚(?:达到)?(\d+(?:\.\d+)?)(?:㎡|平方米)",
],
)
management = extract_area(normalized, [r"(?:生产)?管理用房(?:达到)?(\d+(?:\.\d+)?)(?:㎡|平方米)"])
fodder = extract_area(normalized, [r"(?:饲草料库|草料库|贮草库|储草料库)(?:达到)?(\d+(?:\.\d+)?)(?:㎡|平方米)"])
activity = extract_area(
normalized,
[
r"运动场[^0-9]*(\d+(?:\.\d+)?)(?:㎡|平方米)",
r"巷道圈(\d+(?:\.\d+)?)(?:㎡|平方米)",
r"分区敞圈(\d+(?:\.\d+)?)(?:㎡|平方米)",
],
)
support_type = ""
support_mu = None
support_lower_bound = False
cutting = extract_area(normalized, [r"割草地(\d+(?:\.\d+)?)(?:亩)"])
storage = extract_area(normalized, [r"(?:储草基地|储草料基地)[^0-9]*(\d+(?:\.\d+)?)(?:亩)"])
net_fence = extract_area(normalized, [r"(?:种草)?网围栏(\d+(?:\.\d+)?)(?:亩)"])
if cutting is not None:
support_type, support_mu = "割草地", cutting
elif storage is not None:
support_type, support_mu, support_lower_bound = "储草基地", storage, "不低于" in normalized
elif net_fence is not None:
support_type, support_mu = "网围栏种草", net_fence
return {
"greenhouseArea": greenhouse,
"managementArea": management,
"fodderArea": fodder,
"activityArea": activity,
"grassSupportType": support_type,
"grassSupportMu": support_mu,
"grassSupportLowerBound": support_lower_bound,
}
def find_soffice() -> str:
candidates = [
os.getenv("SOFFICE_PATH"),
"/Applications/LibreOffice.app/Contents/MacOS/soffice",
shutil.which("soffice"),
]
for candidate in candidates:
if candidate and Path(candidate).exists():
return candidate
raise RuntimeError("未找到 LibreOffice soffice,无法读取 .xls 历史台账。")
def convert_xls_to_xlsx(summary_path: Path, work_dir: Path) -> Path:
output_path = work_dir / f"{summary_path.stem}.xlsx"
subprocess.run(
[find_soffice(), "--headless", "--convert-to", "xlsx", "--outdir", str(work_dir), str(summary_path)],
check=True,
capture_output=True,
text=True,
)
if not output_path.exists():
raise RuntimeError("历史台账转换失败,未生成 xlsx 文件。")
return output_path
def parse_summary(summary_path: Path) -> list[dict]:
with tempfile.TemporaryDirectory(prefix="hy-family-pasture-") as temp_dir:
converted = convert_xls_to_xlsx(summary_path, Path(temp_dir))
workbook = openpyxl.load_workbook(converted, data_only=True)
sheet = workbook.worksheets[0]
records = []
for row in sheet.iter_rows(min_row=4, values_only=True):
source_no = number(row[0] if len(row) > 0 else None)
if source_no is None:
continue
town, village, owner = clean(row[1]), clean(row[2]), clean(row[3])
year = number(row[4] if len(row) > 4 else None)
content = clean(row[5] if len(row) > 5 else None)
if not town or not village or not year:
continue
facilities = parse_facilities(content)
records.append(
{
"sourceNo": int(source_no),
"town": town,
"village": village,
"owner": owner,
"constructionYear": int(year),
**facilities,
}
)
return records
def parse_dms_pair(value: object) -> tuple[float, float] | None:
text = clean(value).replace("\n", " ").replace("", "'").replace("", '"')
if not text:
return None
longitude = re.search(r"[Ee]\s*(\d+)°\s*(\d+)'\s*(\d+(?:\.\d+)?)\"?", text)
latitude = re.search(r"[Nn]\s*(\d+)°\s*(\d+)'\s*(\d+(?:\.\d+)?)\"?", text)
if not longitude or not latitude:
return None
lng = int(longitude.group(1)) + int(longitude.group(2)) / 60 + float(longitude.group(3)) / 3600
lat = int(latitude.group(1)) + int(latitude.group(2)) / 60 + float(latitude.group(3)) / 3600
if not (101 <= lng <= 105 and 31 <= lat <= 35):
return None
return round(lng, 8), round(lat, 8)
def parse_points(points_path: Path) -> list[dict]:
workbook = openpyxl.load_workbook(points_path, data_only=True)
sheet = workbook.worksheets[0]
rows = []
town = ""
for row in sheet.iter_rows(min_row=3, values_only=True):
point_no = number(row[0] if len(row) > 0 else None)
if point_no is None:
continue
town = clean(row[1]) or town
point_name, owner = clean(row[2]), clean(row[3])
greenhouse_location = parse_dms_pair(row[5] if len(row) > 5 else None)
management_location = parse_dms_pair(row[6] if len(row) > 6 else None)
village = clean(row[7] if len(row) > 7 else None)
rows.append(
{
"pointNo": int(point_no),
"town": town,
"pointName": point_name,
"owner": owner,
"village": village,
"greenhouseLocation": greenhouse_location,
"managementLocation": management_location,
}
)
return rows
def record_key(record: dict) -> tuple[str, str, str]:
return key_text(record["town"]), key_text(record["village"]), key_text(record["owner"])
def attach_locations(records: list[dict], point_rows: list[dict]) -> list[dict]:
available_by_key: dict[tuple[str, str, str], list[dict]] = defaultdict(list)
available_by_town_village: dict[tuple[str, str], list[dict]] = defaultdict(list)
for record in records:
if record["constructionYear"] != 2025:
continue
available_by_key[record_key(record)].append(record)
available_by_town_village[(key_text(record["town"]), key_text(record["village"]))].append(record)
source_coordinate_count = sum(
1 for point in point_rows if point["greenhouseLocation"] or point["managementLocation"]
)
matched_records: set[int] = set()
match_count = 0
for point in point_rows:
if not (point["greenhouseLocation"] or point["managementLocation"]):
continue
candidates = available_by_key.get(record_key(point), [])
target = next((candidate for candidate in candidates if id(candidate) not in matched_records), None)
if target is None and not point["owner"]:
town_village_key = key_text(point["town"]), key_text(point["village"])
target = next(
(candidate for candidate in available_by_town_village.get(town_village_key, []) if id(candidate) not in matched_records),
None,
)
if target is None:
raise RuntimeError(
f"无法将有坐标的第 {point['pointNo']} 号点匹配至 2025 正式台账:"
f"{point['town']} {point['village']} {point['owner'] or '未填写户主'}"
)
matched_records.add(id(target))
match_count += 1
target["pointNo"] = point["pointNo"]
target["pointName"] = point["pointName"]
target["greenhouseLocation"] = point["greenhouseLocation"]
target["managementLocation"] = point["managementLocation"]
if match_count != source_coordinate_count:
raise RuntimeError(
f"定位补充匹配数量异常:坐标表有 {source_coordinate_count} 条有效坐标,"
f"实际匹配 {match_count} 条。"
)
return records
def format_area(value: int | float | None) -> str:
if value is None:
return "--"
return f"{value:g}"
def build_display_record(record: dict) -> dict:
greenhouse = record["greenhouseArea"]
management = record["managementArea"]
fodder = record["fodderArea"]
activity = record["activityArea"]
support_mu = record["grassSupportMu"]
support_prefix = "" if record["grassSupportLowerBound"] else ""
support_text = (
f"{record['grassSupportType']} {support_prefix}{format_area(support_mu)}"
if record["grassSupportType"] and support_mu is not None
else "未配置"
)
facility_brief = f"{format_area(greenhouse)}㎡暖棚 / {format_area(management)}㎡管理房"
facility_detail = " / ".join(
[
f"{format_area(greenhouse)}㎡标准化暖棚",
f"{format_area(management)}㎡管理用房",
f"{format_area(fodder)}㎡饲草料库",
f"{format_area(activity)}㎡活动场地及通道",
]
)
locations = {}
if record.get("greenhouseLocation"):
locations["暖棚"] = {
"longitude": record["greenhouseLocation"][0],
"latitude": record["greenhouseLocation"][1],
}
if record.get("managementLocation"):
locations["管理用房"] = {
"longitude": record["managementLocation"][0],
"latitude": record["managementLocation"][1],
}
primary = locations.get("管理用房") or locations.get("暖棚")
source_no = record["sourceNo"]
point_no = record.get("pointNo")
identifier = (
f"YC-FAMILY-PASTURE-{point_no:03d}"
if point_no is not None
else f"YC-FAMILY-PASTURE-SUM-{source_no:03d}"
)
owner = record["owner"]
name = f"{owner}家庭生态牧场" if owner else f"{record['town']}{record['village']}{source_no}号家庭生态牧场"
metrics = {
"标准化暖棚 (㎡)": greenhouse,
"管理用房 (㎡)": management,
"饲草料库 (㎡)": fodder,
"活动场地及通道 (㎡)": activity,
}
if support_mu is not None:
metrics["配套草料地 (亩)"] = support_mu
attributes = {
"设施配置": facility_detail,
"草料配套": support_text,
}
if owner:
attributes["户主"] = owner
return {
"id": identifier,
"name": name,
"town": record["town"],
"village": record["village"],
"owner": owner,
"constructionYear": record["constructionYear"],
"sourceNo": source_no,
"pointName": record.get("pointName", ""),
"facilityBrief": facility_brief,
"facilityDetail": facility_detail,
"grassSupportType": record["grassSupportType"],
"grassSupportMu": support_mu,
"grassSupportLowerBound": record["grassSupportLowerBound"],
"grassSupportText": support_text,
"longitude": primary["longitude"] if primary else None,
"latitude": primary["latitude"] if primary else None,
"locations": locations,
"metrics": metrics,
"attributes": attributes,
"sourceSheet": "红原县2023年至今建设家庭牧场汇总表",
}
def verify_records(records: list[dict], expected_coordinate_count: int) -> None:
if len(records) != 252:
raise RuntimeError(f"正式建设台账数量异常:期望 252 条,实际 {len(records)} 条。")
coordinate_count = sum(1 for record in records if record.get("longitude") and record.get("latitude"))
if coordinate_count != expected_coordinate_count:
raise RuntimeError(f"可定位台账数量异常:期望 {expected_coordinate_count} 条,实际 {coordinate_count} 条。")
expected_totals = {
"标准化暖棚 (㎡)": 61600,
"管理用房 (㎡)": 11920,
"饲草料库 (㎡)": 12000,
"活动场地及通道 (㎡)": 108800,
}
for metric, expected in expected_totals.items():
actual = sum(record["metrics"].get(metric, 0) or 0 for record in records)
if actual != expected:
raise RuntimeError(f"{metric} 汇总异常:期望 {expected},实际 {actual}")
def main() -> None:
parser = argparse.ArgumentParser(description="生成家庭生态牧场大屏数据")
parser.add_argument("--summary", type=Path, default=DEFAULT_SUMMARY)
parser.add_argument("--points", type=Path, default=DEFAULT_POINTS)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
if not args.summary.exists():
raise FileNotFoundError(f"未找到历史台账:{args.summary}")
if not args.points.exists():
raise FileNotFoundError(f"未找到 2025 定位表:{args.points}")
point_rows = parse_points(args.points)
expected_coordinate_count = sum(
1 for point in point_rows if point["greenhouseLocation"] or point["managementLocation"]
)
records = attach_locations(parse_summary(args.summary), point_rows)
display_records = [build_display_record(record) for record in records]
verify_records(display_records, expected_coordinate_count)
payload = {
"datasetName": "红原县家庭生态牧场建设台账",
"sourceSummary": args.summary.name,
"sourceCoordinates": args.points.name,
"recordUnit": "",
"recordCount": len(display_records),
"locatedRecordCount": sum(1 for record in display_records if record.get("longitude") and record.get("latitude")),
"records": display_records,
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(
f"已生成 {args.output}: {payload['recordCount']} 条正式台账,"
f"{payload['locatedRecordCount']} 条可定位。"
)
if __name__ == "__main__":
main()