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...

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()