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.
808 lines
33 KiB
808 lines
33 KiB
#!/usr/bin/env python3
|
|
"""Build the family ecological pasture dataset used by the Yak Industry Chain screen.
|
|
|
|
The construction ledger is the authoritative record list. All display
|
|
coordinates come from the map marker workbook, then get matched back to ledger
|
|
records by administrative township and village.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from collections import Counter, defaultdict
|
|
from datetime import datetime, timezone, timedelta
|
|
from pathlib import Path
|
|
|
|
import openpyxl
|
|
|
|
|
|
SCRIPT_ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_SUMMARY = Path(
|
|
"/Users/mocker/Documents/hy/数据清单(1)/家庭牧场/红原县2023年至今建设家庭牧场汇总表.xls"
|
|
)
|
|
DEFAULT_OWNER_POINTS = Path(
|
|
"/Users/mocker/Documents/hy/数据清单(1)/家庭牧场/红原县2025年适度规模化(生态家庭牧场)家庭牧场项目-户主信息表.xlsx"
|
|
)
|
|
DEFAULT_MARKER_POINTS = Path(
|
|
"/Users/mocker/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/"
|
|
"wxid_k77f4cxbgk8y52_8fdc/msg/file/2026-07/地图标记家庭牧场点位.xlsx"
|
|
)
|
|
DEFAULT_OUTPUT = SCRIPT_ROOT / "src/views/yakIndustryChain/familyPastureData.json"
|
|
DEFAULT_GEOJSON_OUTPUT = SCRIPT_ROOT / "public/datas/yak-industry-chain/yak_industry_points.geojson"
|
|
DEFAULT_ADMIN_VILLAGES = SCRIPT_ROOT / "public/datas/geojsons/hongyuan-villages.geojson"
|
|
DEFAULT_ADMIN_TOWNSHIPS = SCRIPT_ROOT / "public/datas/geojsons/hongyuan-townships.geojson"
|
|
|
|
VILLAGE_ALIASES = {
|
|
("瓦切镇", "色永村"): ("瓦切镇", "色尔永村"),
|
|
("色地镇", "让里村"): ("色地镇", "壤里村"),
|
|
("龙日镇", "龙日坝村"): ("龙日镇", "四川省龙日种畜场"),
|
|
}
|
|
|
|
|
|
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"),
|
|
"/Users/mocker/.cache/codex-runtimes/codex-primary-runtime/dependencies/bin/override/soffice",
|
|
"/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_marker_points(marker_path: Path) -> list[dict]:
|
|
workbook = openpyxl.load_workbook(marker_path, data_only=True)
|
|
for sheet in workbook.worksheets:
|
|
rows = list(sheet.iter_rows(values_only=True))
|
|
for header_index, header_row in enumerate(rows):
|
|
headers = [key_text(value) for value in header_row]
|
|
if "序号" not in headers or ("经纬度" not in headers and not {"经度", "纬度"}.issubset(set(headers))):
|
|
continue
|
|
header_map = {name: index for index, name in enumerate(headers) if name}
|
|
marker_rows = []
|
|
for row in rows[header_index + 1:]:
|
|
point_no = number(value_at(row, header_map.get("序号")))
|
|
if point_no is None:
|
|
continue
|
|
coordinate = parse_marker_coordinate(row, header_map)
|
|
if coordinate is None:
|
|
continue
|
|
longitude, latitude = coordinate
|
|
marker_rows.append(
|
|
{
|
|
"markerNo": int(point_no),
|
|
"longitude": longitude,
|
|
"latitude": latitude,
|
|
"sourceSheet": sheet.title,
|
|
}
|
|
)
|
|
if marker_rows:
|
|
return marker_rows
|
|
raise RuntimeError(f"未能在地图标记文件中识别有效经纬度:{marker_path}")
|
|
|
|
|
|
def value_at(row: tuple, index: int | None) -> object:
|
|
if index is None or index >= len(row):
|
|
return None
|
|
return row[index]
|
|
|
|
|
|
def parse_marker_coordinate(row: tuple, header_map: dict[str, int]) -> tuple[float, float] | None:
|
|
longitude = number(value_at(row, header_map.get("经度")))
|
|
latitude = number(value_at(row, header_map.get("纬度")))
|
|
if longitude is None or latitude is None:
|
|
text = clean(value_at(row, header_map.get("经纬度")))
|
|
matched = re.search(r"point\(\s*([+-]?\d+(?:\.\d+)?)\s+([+-]?\d+(?:\.\d+)?)\s*\)", text, re.IGNORECASE)
|
|
if matched:
|
|
longitude, latitude = float(matched.group(1)), float(matched.group(2))
|
|
if longitude is None or latitude is None:
|
|
return None
|
|
longitude, latitude = float(longitude), float(latitude)
|
|
if not (101 <= longitude <= 105 and 31 <= latitude <= 35):
|
|
return None
|
|
return round(longitude, 8), round(latitude, 8)
|
|
|
|
|
|
def load_geojson(path: Path) -> dict:
|
|
if not path.exists():
|
|
return {"type": "FeatureCollection", "features": []}
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def load_admin_areas(villages_path: Path, townships_path: Path) -> tuple[list[dict], list[dict]]:
|
|
village_areas = []
|
|
for feature in load_geojson(villages_path).get("features", []):
|
|
geometry = feature.get("geometry")
|
|
if not geometry:
|
|
continue
|
|
props = feature.get("properties") or {}
|
|
town = clean(props.get("town") or props.get("townName") or props.get("town_name"))
|
|
village = clean(props.get("name") or props.get("village") or props.get("villageName"))
|
|
if not town or not village:
|
|
continue
|
|
village_areas.append(
|
|
{
|
|
"town": town,
|
|
"village": village,
|
|
"code": clean(props.get("code")),
|
|
"geometry": geometry,
|
|
"bbox": geometry_bbox(geometry),
|
|
"center": geometry_center(geometry),
|
|
}
|
|
)
|
|
|
|
township_areas = []
|
|
for feature in load_geojson(townships_path).get("features", []):
|
|
geometry = feature.get("geometry")
|
|
if not geometry:
|
|
continue
|
|
props = feature.get("properties") or {}
|
|
town = clean(props.get("name") or props.get("town") or props.get("townName"))
|
|
if not town:
|
|
continue
|
|
center = props.get("center") or props.get("cp") or geometry_center(geometry)
|
|
township_areas.append(
|
|
{
|
|
"town": town,
|
|
"geometry": geometry,
|
|
"bbox": geometry_bbox(geometry),
|
|
"center": normalize_point(center) or geometry_center(geometry),
|
|
}
|
|
)
|
|
return village_areas, township_areas
|
|
|
|
|
|
def normalize_point(value: object) -> tuple[float, float] | None:
|
|
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
|
return None
|
|
try:
|
|
return float(value[0]), float(value[1])
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def geometry_bbox(geometry: dict) -> tuple[float, float, float, float]:
|
|
points = list(iter_geometry_points(geometry))
|
|
if not points:
|
|
return (math.inf, math.inf, -math.inf, -math.inf)
|
|
longitudes = [point[0] for point in points]
|
|
latitudes = [point[1] for point in points]
|
|
return min(longitudes), min(latitudes), max(longitudes), max(latitudes)
|
|
|
|
|
|
def geometry_center(geometry: dict) -> tuple[float, float] | None:
|
|
west, south, east, north = geometry_bbox(geometry)
|
|
if not all(math.isfinite(value) for value in (west, south, east, north)):
|
|
return None
|
|
return (west + east) / 2, (south + north) / 2
|
|
|
|
|
|
def iter_geometry_points(geometry: dict):
|
|
geometry_type = geometry.get("type")
|
|
coordinates = geometry.get("coordinates") or []
|
|
if geometry_type == "Point":
|
|
yield coordinates
|
|
elif geometry_type == "Polygon":
|
|
for ring in coordinates:
|
|
yield from ring
|
|
elif geometry_type == "MultiPolygon":
|
|
for polygon in coordinates:
|
|
for ring in polygon:
|
|
yield from ring
|
|
|
|
|
|
def bbox_contains(bbox: tuple[float, float, float, float], longitude: float, latitude: float) -> bool:
|
|
west, south, east, north = bbox
|
|
return west <= longitude <= east and south <= latitude <= north
|
|
|
|
|
|
def point_in_geometry(geometry: dict, longitude: float, latitude: float) -> bool:
|
|
geometry_type = geometry.get("type")
|
|
coordinates = geometry.get("coordinates") or []
|
|
point = (longitude, latitude)
|
|
if geometry_type == "Polygon":
|
|
return point_in_polygon(point, coordinates)
|
|
if geometry_type == "MultiPolygon":
|
|
return any(point_in_polygon(point, polygon) for polygon in coordinates)
|
|
return False
|
|
|
|
|
|
def point_in_polygon(point: tuple[float, float], polygon: list) -> bool:
|
|
if not polygon or not point_in_ring(point, polygon[0]):
|
|
return False
|
|
return not any(point_in_ring(point, hole) for hole in polygon[1:])
|
|
|
|
|
|
def point_in_ring(point: tuple[float, float], ring: list) -> bool:
|
|
x, y = point
|
|
inside = False
|
|
if len(ring) < 3:
|
|
return False
|
|
previous = ring[-1]
|
|
for current in ring:
|
|
x1, y1 = float(previous[0]), float(previous[1])
|
|
x2, y2 = float(current[0]), float(current[1])
|
|
if point_on_segment(x, y, x1, y1, x2, y2):
|
|
return True
|
|
intersects = (y1 > y) != (y2 > y)
|
|
if intersects:
|
|
x_intersection = (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-30) + x1
|
|
if x <= x_intersection:
|
|
inside = not inside
|
|
previous = current
|
|
return inside
|
|
|
|
|
|
def point_on_segment(x: float, y: float, x1: float, y1: float, x2: float, y2: float) -> bool:
|
|
cross = (x - x1) * (y2 - y1) - (y - y1) * (x2 - x1)
|
|
if abs(cross) > 1e-10:
|
|
return False
|
|
return min(x1, x2) - 1e-10 <= x <= max(x1, x2) + 1e-10 and min(y1, y2) - 1e-10 <= y <= max(y1, y2) + 1e-10
|
|
|
|
|
|
def assign_admin_to_markers(marker_points: list[dict], village_areas: list[dict], township_areas: list[dict]) -> list[dict]:
|
|
assigned = []
|
|
for marker in marker_points:
|
|
longitude, latitude = marker["longitude"], marker["latitude"]
|
|
village_area = first_containing_area(village_areas, longitude, latitude)
|
|
township_area = None if village_area else first_containing_area(township_areas, longitude, latitude)
|
|
admin_town = village_area["town"] if village_area else township_area["town"] if township_area else ""
|
|
admin_village = village_area["village"] if village_area else ""
|
|
assigned.append(
|
|
{
|
|
**marker,
|
|
"adminTown": admin_town,
|
|
"adminVillage": admin_village,
|
|
"adminVillageCode": village_area["code"] if village_area else "",
|
|
"adminMatchLevel": "village" if village_area else "town" if township_area else "outside",
|
|
}
|
|
)
|
|
return assigned
|
|
|
|
|
|
def first_containing_area(areas: list[dict], longitude: float, latitude: float) -> dict | None:
|
|
for area in areas:
|
|
if bbox_contains(area["bbox"], longitude, latitude) and point_in_geometry(area["geometry"], longitude, latitude):
|
|
return area
|
|
return None
|
|
|
|
|
|
def aliased_location(town: str, village: str) -> tuple[str, str]:
|
|
return VILLAGE_ALIASES.get((clean(town), clean(village)), (clean(town), clean(village)))
|
|
|
|
|
|
def town_key(town: str) -> str:
|
|
return key_text(town)
|
|
|
|
|
|
def town_village_key(town: str, village: str) -> tuple[str, str]:
|
|
aliased_town, aliased_village = aliased_location(town, village)
|
|
return town_key(aliased_town), key_text(aliased_village)
|
|
|
|
|
|
def marker_town_key(marker: dict) -> str:
|
|
return town_key(marker.get("adminTown", ""))
|
|
|
|
|
|
def marker_town_village_key(marker: dict) -> tuple[str, str]:
|
|
return town_key(marker.get("adminTown", "")), key_text(marker.get("adminVillage", ""))
|
|
|
|
|
|
def build_center_indexes(village_areas: list[dict], township_areas: list[dict]) -> tuple[dict[tuple[str, str], tuple[float, float]], dict[str, tuple[float, float]]]:
|
|
village_centers = {
|
|
town_village_key(area["town"], area["village"]): area["center"]
|
|
for area in village_areas
|
|
if area.get("center")
|
|
}
|
|
town_centers = {
|
|
town_key(area["town"]): area["center"]
|
|
for area in township_areas
|
|
if area.get("center")
|
|
}
|
|
return village_centers, town_centers
|
|
|
|
|
|
def attach_marker_locations(records: list[dict], markers: list[dict], village_areas: list[dict], township_areas: list[dict]) -> tuple[list[dict], Counter]:
|
|
if len(markers) < len(records):
|
|
raise RuntimeError(f"地图标记点数量不足:台账 {len(records)} 条,标记点 {len(markers)} 个。")
|
|
|
|
by_town_village: dict[tuple[str, str], list[int]] = defaultdict(list)
|
|
by_town: dict[str, list[int]] = defaultdict(list)
|
|
for index, marker in enumerate(markers):
|
|
if marker.get("adminTown"):
|
|
by_town_village[marker_town_village_key(marker)].append(index)
|
|
by_town[marker_town_key(marker)].append(index)
|
|
|
|
village_centers, town_centers = build_center_indexes(village_areas, township_areas)
|
|
assignments: list[tuple[dict, str] | None] = [None] * len(records)
|
|
unused = set(range(len(markers)))
|
|
stats: Counter = Counter()
|
|
|
|
def assign(record_index: int, candidates: list[int], level: str, center: tuple[float, float] | None) -> None:
|
|
candidate = choose_marker(candidates, markers, unused, center)
|
|
if candidate is None:
|
|
return
|
|
unused.remove(candidate)
|
|
assignments[record_index] = (markers[candidate], level)
|
|
stats[level] += 1
|
|
|
|
for record_index, record in enumerate(records):
|
|
key = town_village_key(record["town"], record["village"])
|
|
assign(record_index, by_town_village.get(key, []), "townVillage", village_centers.get(key))
|
|
|
|
for record_index, record in enumerate(records):
|
|
if assignments[record_index] is not None:
|
|
continue
|
|
key = town_key(record["town"])
|
|
assign(record_index, by_town.get(key, []), "town", town_centers.get(key))
|
|
|
|
for record_index, record in enumerate(records):
|
|
if assignments[record_index] is not None:
|
|
continue
|
|
key = town_key(record["town"])
|
|
assign(record_index, list(unused), "fallback", town_centers.get(key))
|
|
|
|
matched_records = []
|
|
for record, assignment in zip(records, assignments):
|
|
if assignment is None:
|
|
raise RuntimeError(f"台账第 {record['sourceNo']} 条未能匹配地图标记点。")
|
|
marker, match_level = assignment
|
|
matched_records.append(
|
|
{
|
|
**record,
|
|
"markerNo": marker["markerNo"],
|
|
"markerLongitude": marker["longitude"],
|
|
"markerLatitude": marker["latitude"],
|
|
"markerAdminTown": marker.get("adminTown", ""),
|
|
"markerAdminVillage": marker.get("adminVillage", ""),
|
|
"markerAdminVillageCode": marker.get("adminVillageCode", ""),
|
|
"markerAdminMatchLevel": marker.get("adminMatchLevel", ""),
|
|
"coordinateMatchLevel": match_level,
|
|
}
|
|
)
|
|
return matched_records, stats
|
|
|
|
|
|
def choose_marker(candidates: list[int], markers: list[dict], unused: set[int], center: tuple[float, float] | None) -> int | None:
|
|
available = [index for index in candidates if index in unused]
|
|
if not available:
|
|
return None
|
|
if center:
|
|
return min(available, key=lambda index: (distance_sq(markers[index], center), markers[index]["markerNo"]))
|
|
return min(available, key=lambda index: markers[index]["markerNo"])
|
|
|
|
|
|
def distance_sq(marker: dict, center: tuple[float, float]) -> float:
|
|
longitude, latitude = center
|
|
return (marker["longitude"] - longitude) ** 2 + (marker["latitude"] - latitude) ** 2
|
|
|
|
|
|
def format_area(value: int | float | None) -> str:
|
|
if value is None:
|
|
return "--"
|
|
return f"{value:g}"
|
|
|
|
|
|
def format_match_text(record: dict) -> str:
|
|
labels = {
|
|
"townVillage": "同镇同村",
|
|
"town": "同镇",
|
|
"fallback": "回退匹配",
|
|
}
|
|
marker_area = " · ".join([record.get("markerAdminTown", ""), record.get("markerAdminVillage", "")]).strip(" ·")
|
|
marker_label = f"地图标记{record['markerNo']}号点"
|
|
if marker_area:
|
|
marker_label = f"{marker_label}({marker_area})"
|
|
return f"{labels.get(record.get('coordinateMatchLevel'), '匹配')}:{marker_label}"
|
|
|
|
|
|
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)}㎡活动场地及通道",
|
|
]
|
|
)
|
|
|
|
source_no = record["sourceNo"]
|
|
marker_no = record["markerNo"]
|
|
owner = record["owner"]
|
|
identifier = f"YC-FAMILY-PASTURE-{source_no:03d}"
|
|
name = f"{owner}家庭生态牧场" if owner else f"{record['town']}{record['village']}{source_no}号家庭生态牧场"
|
|
match_text = format_match_text(record)
|
|
locations = {
|
|
"地图标记": {
|
|
"longitude": record["markerLongitude"],
|
|
"latitude": record["markerLatitude"],
|
|
"markerNo": marker_no,
|
|
}
|
|
}
|
|
metrics = {
|
|
"标准化暖棚 (㎡)": greenhouse,
|
|
"管理用房 (㎡)": management,
|
|
"饲草料库 (㎡)": fodder,
|
|
"活动场地及通道 (㎡)": activity,
|
|
}
|
|
if support_mu is not None:
|
|
metrics["配套草料地 (亩)"] = support_mu
|
|
attributes = {
|
|
"设施配置": facility_detail,
|
|
"草料配套": support_text,
|
|
"地图标记": f"{marker_no}号点",
|
|
"点位匹配": match_text,
|
|
}
|
|
marker_area = " · ".join([record.get("markerAdminTown", ""), record.get("markerAdminVillage", "")]).strip(" ·")
|
|
if marker_area:
|
|
attributes["标记归属"] = marker_area
|
|
if owner:
|
|
attributes["户主"] = owner
|
|
return {
|
|
"id": identifier,
|
|
"name": name,
|
|
"town": record["town"],
|
|
"village": record["village"],
|
|
"owner": owner,
|
|
"constructionYear": record["constructionYear"],
|
|
"sourceNo": source_no,
|
|
"pointName": f"地图标记{marker_no}号点",
|
|
"matchedPointNo": marker_no,
|
|
"matchedPointTown": record.get("markerAdminTown", ""),
|
|
"matchedPointVillage": record.get("markerAdminVillage", ""),
|
|
"coordinateMatchLevel": record.get("coordinateMatchLevel", ""),
|
|
"coordinateMatchText": match_text,
|
|
"facilityBrief": facility_brief,
|
|
"facilityDetail": facility_detail,
|
|
"grassSupportType": record["grassSupportType"],
|
|
"grassSupportMu": support_mu,
|
|
"grassSupportLowerBound": record["grassSupportLowerBound"],
|
|
"grassSupportText": support_text,
|
|
"longitude": record["markerLongitude"],
|
|
"latitude": record["markerLatitude"],
|
|
"locations": locations,
|
|
"metrics": metrics,
|
|
"attributes": attributes,
|
|
"sourceSheet": "红原县2023年至今建设家庭牧场汇总表",
|
|
}
|
|
|
|
|
|
def build_marker_record(marker: dict, matched_marker_numbers: set[int]) -> dict:
|
|
marker_no = marker["markerNo"]
|
|
town = marker.get("adminTown", "") or "未识别乡镇"
|
|
village = marker.get("adminVillage", "")
|
|
marker_label = f"地图标记{marker_no}号点"
|
|
return {
|
|
"id": f"YC-FAMILY-PASTURE-MARKER-{marker_no:03d}",
|
|
"name": marker_label,
|
|
"town": town,
|
|
"village": village,
|
|
"markerNo": marker_no,
|
|
"longitude": marker["longitude"],
|
|
"latitude": marker["latitude"],
|
|
"markerOnly": marker_no not in matched_marker_numbers,
|
|
"adminMatchLevel": marker.get("adminMatchLevel", ""),
|
|
"sourceSheet": marker.get("sourceSheet", ""),
|
|
}
|
|
|
|
|
|
def verify_records(records: list[dict]) -> 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 != len(records):
|
|
raise RuntimeError(f"可定位台账数量异常:期望 {len(records)} 条,实际 {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 build_family_features(records: list[dict], markers: list[dict]) -> list[dict]:
|
|
features = []
|
|
matched_marker_numbers = set()
|
|
for record in records:
|
|
matched_marker_numbers.add(record["matchedPointNo"])
|
|
features.append(
|
|
{
|
|
"type": "Feature",
|
|
"properties": {
|
|
"id": record["id"],
|
|
"name": record["name"],
|
|
"owner": record["owner"],
|
|
"constructionYear": record["constructionYear"],
|
|
"stageKey": "family-pasture",
|
|
"stageName": "家庭生态牧场",
|
|
"categoryCode": "YC-FAMILY-PASTURE",
|
|
"categoryName": "家庭生态牧场",
|
|
"town": record["town"],
|
|
"village": record["village"],
|
|
"matchedPointNo": record["matchedPointNo"],
|
|
"pointName": record["pointName"],
|
|
"matchedPointTown": record.get("matchedPointTown", ""),
|
|
"matchedPointVillage": record.get("matchedPointVillage", ""),
|
|
"coordinateMatchLevel": record.get("coordinateMatchLevel", ""),
|
|
"coordinateMatchText": record.get("coordinateMatchText", ""),
|
|
"address": record["village"],
|
|
"capacity": 1,
|
|
"capacityUnit": "处",
|
|
"contactName": record["owner"],
|
|
"contactPhone": "",
|
|
"status": f"{record['constructionYear']}年建设",
|
|
"locationType": "地图标记",
|
|
"sourceSheet": "地图标记家庭牧场点位",
|
|
},
|
|
"geometry": {
|
|
"type": "Point",
|
|
"coordinates": [record["longitude"], record["latitude"]],
|
|
},
|
|
}
|
|
)
|
|
for marker in sorted(markers, key=lambda item: item["markerNo"]):
|
|
if marker["markerNo"] in matched_marker_numbers:
|
|
continue
|
|
marker_area = " · ".join([marker.get("adminTown", ""), marker.get("adminVillage", "")]).strip(" ·")
|
|
marker_label = f"地图标记{marker['markerNo']}号点"
|
|
features.append(
|
|
{
|
|
"type": "Feature",
|
|
"properties": {
|
|
"id": f"YC-FAMILY-PASTURE-MARKER-{marker['markerNo']:03d}",
|
|
"name": marker_label,
|
|
"stageKey": "family-pasture",
|
|
"stageName": "家庭生态牧场",
|
|
"categoryCode": "YC-FAMILY-PASTURE",
|
|
"categoryName": "家庭生态牧场",
|
|
"town": marker.get("adminTown", "") or "未识别乡镇",
|
|
"village": marker.get("adminVillage", ""),
|
|
"address": marker.get("adminVillage", "") or marker.get("adminTown", ""),
|
|
"matchedPointNo": marker["markerNo"],
|
|
"pointName": marker_label,
|
|
"matchedPointTown": marker.get("adminTown", ""),
|
|
"matchedPointVillage": marker.get("adminVillage", ""),
|
|
"coordinateMatchLevel": "markerOnly",
|
|
"coordinateMatchText": f"地图原始标记点(未匹配建设台账){f':{marker_area}' if marker_area else ''}",
|
|
"capacity": 1,
|
|
"capacityUnit": "处",
|
|
"contactName": "",
|
|
"contactPhone": "",
|
|
"status": "地图标记点位",
|
|
"locationType": "地图标记",
|
|
"markerOnly": True,
|
|
"markerAdminMatchLevel": marker.get("adminMatchLevel", ""),
|
|
"sourceSheet": "地图标记家庭牧场点位",
|
|
},
|
|
"geometry": {
|
|
"type": "Point",
|
|
"coordinates": [marker["longitude"], marker["latitude"]],
|
|
},
|
|
}
|
|
)
|
|
return features
|
|
|
|
|
|
def write_industry_points_geojson(output_path: Path, records: list[dict], markers: list[dict]) -> int:
|
|
family_features = build_family_features(records, markers)
|
|
existing = load_geojson(output_path)
|
|
preserved = [
|
|
feature for feature in existing.get("features", [])
|
|
if feature.get("properties", {}).get("categoryCode") != "YC-FAMILY-PASTURE"
|
|
]
|
|
payload = {
|
|
"type": "FeatureCollection",
|
|
"name": existing.get("name") or "yak_industry_points",
|
|
"generatedAt": generated_at(),
|
|
"features": [*family_features, *preserved],
|
|
}
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
return len(family_features)
|
|
|
|
|
|
def generated_at() -> str:
|
|
china_tz = timezone(timedelta(hours=8))
|
|
return datetime.now(china_tz).isoformat(timespec="seconds")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="生成家庭生态牧场大屏数据")
|
|
parser.add_argument("--summary", type=Path, default=DEFAULT_SUMMARY)
|
|
parser.add_argument("--owner-points", type=Path, default=DEFAULT_OWNER_POINTS)
|
|
parser.add_argument("--markers", type=Path, default=DEFAULT_MARKER_POINTS)
|
|
parser.add_argument("--admin-villages", type=Path, default=DEFAULT_ADMIN_VILLAGES)
|
|
parser.add_argument("--admin-townships", type=Path, default=DEFAULT_ADMIN_TOWNSHIPS)
|
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
|
parser.add_argument("--geojson-output", type=Path, default=DEFAULT_GEOJSON_OUTPUT)
|
|
args = parser.parse_args()
|
|
if not args.summary.exists():
|
|
raise FileNotFoundError(f"未找到历史台账:{args.summary}")
|
|
if not args.markers.exists():
|
|
raise FileNotFoundError(f"未找到地图标记点位表:{args.markers}")
|
|
|
|
village_areas, township_areas = load_admin_areas(args.admin_villages, args.admin_townships)
|
|
marker_points = assign_admin_to_markers(parse_marker_points(args.markers), village_areas, township_areas)
|
|
records, match_stats = attach_marker_locations(parse_summary(args.summary), marker_points, village_areas, township_areas)
|
|
display_records = [build_display_record(record) for record in records]
|
|
verify_records(display_records)
|
|
matched_marker_numbers = {record["matchedPointNo"] for record in display_records}
|
|
marker_records = [build_marker_record(marker, matched_marker_numbers) for marker in marker_points]
|
|
payload = {
|
|
"datasetName": "红原县家庭生态牧场建设台账",
|
|
"sourceSummary": args.summary.name,
|
|
"sourceCoordinates": args.markers.name,
|
|
"sourceOwnerCoordinates": args.owner_points.name if args.owner_points.exists() else "",
|
|
"sourceAdminAreas": args.admin_villages.name if args.admin_villages.exists() else args.admin_townships.name,
|
|
"recordUnit": "处",
|
|
"recordCount": len(display_records),
|
|
"locatedRecordCount": sum(1 for record in display_records if record.get("longitude") and record.get("latitude")),
|
|
"markerPointCount": len(marker_points),
|
|
"renderedMarkerPointCount": len(marker_points),
|
|
"unmatchedMarkerPointCount": max(0, len(marker_points) - len(display_records)),
|
|
"coordinateMatchSummary": {
|
|
"townVillage": match_stats.get("townVillage", 0),
|
|
"town": match_stats.get("town", 0),
|
|
"fallback": match_stats.get("fallback", 0),
|
|
},
|
|
"records": display_records,
|
|
"markerPoints": marker_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")
|
|
family_feature_count = write_industry_points_geojson(args.geojson_output, display_records, marker_points)
|
|
print(
|
|
f"已生成 {args.output}: {payload['recordCount']} 条正式台账,"
|
|
f"{payload['locatedRecordCount']} 条使用地图标记点定位。"
|
|
)
|
|
print(
|
|
"点位匹配:"
|
|
f"同镇同村 {payload['coordinateMatchSummary']['townVillage']} 条,"
|
|
f"同镇 {payload['coordinateMatchSummary']['town']} 条,"
|
|
f"回退 {payload['coordinateMatchSummary']['fallback']} 条。"
|
|
)
|
|
print(
|
|
f"已更新 {args.geojson_output}: {family_feature_count} 条家庭生态牧场地图点位,"
|
|
f"其中 {payload['unmatchedMarkerPointCount']} 条为未匹配台账的地图原始标记。"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
|