820 lines
27 KiB
Python
820 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import csv
|
|
import io
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException, UploadFile, status
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..models.base import utcnow
|
|
from ..models.line import Line
|
|
from ..models.line_tower import LineTower
|
|
from ..schemas.line import (
|
|
LineCreateRequest,
|
|
LineListResponse,
|
|
LineSummary,
|
|
LineTowerCreateRequest,
|
|
LineTowerImportResponse,
|
|
LineTowerListResponse,
|
|
LineTowerSummary,
|
|
LineTowerUpdateRequest,
|
|
LineUpdateRequest,
|
|
)
|
|
from .push_service import publish_topic
|
|
|
|
LINE_TOPIC = "admin.power-lines"
|
|
CSV_ENCODINGS = ("utf-8-sig", "utf-8", "gbk", "latin-1")
|
|
|
|
|
|
@dataclass
|
|
class CsvImportStats:
|
|
imported_count: int = 0
|
|
updated_count: int = 0
|
|
skipped_count: int = 0
|
|
warnings: list[str] | None = None
|
|
|
|
def __post_init__(self) -> None:
|
|
if self.warnings is None:
|
|
self.warnings = []
|
|
|
|
|
|
def serialize_line(line: Line, *, tower_count: int = 0) -> LineSummary:
|
|
return LineSummary(
|
|
id=line.id,
|
|
code=line.code,
|
|
name=line.name,
|
|
voltage_kv=line.voltage_kv,
|
|
tower_shape=line.tower_shape,
|
|
phase_sequence_json=line.phase_sequence_json or {},
|
|
arrester_install_json=line.arrester_install_json or {},
|
|
lightning_param_json=line.lightning_param_json or {},
|
|
status=line.status, # type: ignore[arg-type]
|
|
tower_count=tower_count,
|
|
create_date=line.create_date,
|
|
create_user=line.create_user,
|
|
update_date=line.update_date,
|
|
update_user=line.update_user,
|
|
)
|
|
|
|
|
|
def serialize_line_tower(item: LineTower) -> LineTowerSummary:
|
|
return LineTowerSummary(
|
|
id=item.id,
|
|
line_id=item.line_id,
|
|
seq_no=item.seq_no,
|
|
tower_no=item.tower_no,
|
|
tower_model=item.tower_model,
|
|
tower_type=item.tower_type,
|
|
longitude=item.longitude,
|
|
latitude=item.latitude,
|
|
altitude_m=item.altitude_m,
|
|
terrain=item.terrain,
|
|
ground_resistance_ohm=item.ground_resistance_ohm,
|
|
lightning_density=item.lightning_density,
|
|
span_small_m=item.span_small_m,
|
|
span_large_m=item.span_large_m,
|
|
slope_1=item.slope_1,
|
|
slope_2=item.slope_2,
|
|
risk_level=item.risk_level,
|
|
circuit_geometry_json=item.circuit_geometry_json or {},
|
|
lightning_result_json=item.lightning_result_json or {},
|
|
raw_extra_json=item.raw_extra_json or {},
|
|
create_date=item.create_date,
|
|
create_user=item.create_user,
|
|
update_date=item.update_date,
|
|
update_user=item.update_user,
|
|
)
|
|
|
|
|
|
def list_lines(
|
|
db: Session,
|
|
*,
|
|
keyword: str | None,
|
|
status_filter: str | None,
|
|
) -> LineListResponse:
|
|
stmt = select(Line)
|
|
total_stmt = select(func.count()).select_from(Line)
|
|
|
|
normalized = (keyword or "").strip()
|
|
if normalized:
|
|
like = f"%{normalized}%"
|
|
predicate = or_(Line.code.ilike(like), Line.name.ilike(like))
|
|
stmt = stmt.where(predicate)
|
|
total_stmt = total_stmt.where(predicate)
|
|
|
|
if status_filter in {"enabled", "disabled"}:
|
|
stmt = stmt.where(Line.status == status_filter)
|
|
total_stmt = total_stmt.where(Line.status == status_filter)
|
|
|
|
total = int(db.scalar(total_stmt) or 0)
|
|
items = db.execute(stmt.order_by(Line.update_date.desc(), Line.code.asc())).scalars().all()
|
|
line_ids = [item.id for item in items]
|
|
tower_count_map = _load_tower_counts(db, line_ids)
|
|
|
|
return LineListResponse(
|
|
items=[serialize_line(item, tower_count=tower_count_map.get(item.id, 0)) for item in items],
|
|
total=total,
|
|
)
|
|
|
|
|
|
def get_line_by_id(db: Session, line_id: str) -> Line | None:
|
|
return db.execute(select(Line).where(Line.id == line_id)).scalar_one_or_none()
|
|
|
|
|
|
def get_line_by_code(db: Session, code: str) -> Line | None:
|
|
normalized = code.strip()
|
|
if not normalized:
|
|
return None
|
|
return db.execute(select(Line).where(func.lower(Line.code) == normalized.lower())).scalar_one_or_none()
|
|
|
|
|
|
def create_line(
|
|
db: Session,
|
|
payload: LineCreateRequest,
|
|
*,
|
|
actor_user_id: str,
|
|
) -> LineSummary | None:
|
|
if get_line_by_code(db, payload.code):
|
|
return None
|
|
|
|
now = utcnow()
|
|
line = Line(
|
|
code=payload.code.strip(),
|
|
name=payload.name.strip(),
|
|
voltage_kv=payload.voltage_kv,
|
|
tower_shape=_normalize_str(payload.tower_shape),
|
|
phase_sequence_json=payload.phase_sequence_json,
|
|
arrester_install_json=payload.arrester_install_json,
|
|
lightning_param_json=payload.lightning_param_json,
|
|
status=payload.status,
|
|
create_user=actor_user_id,
|
|
update_user=actor_user_id,
|
|
create_date=now,
|
|
update_date=now,
|
|
)
|
|
db.add(line)
|
|
db.commit()
|
|
|
|
saved = get_line_by_id(db, line.id)
|
|
if not saved:
|
|
return None
|
|
|
|
_publish_line_change("power-lines.created", {"action": "created", "line_id": saved.id})
|
|
return serialize_line(saved, tower_count=0)
|
|
|
|
|
|
def update_line(
|
|
db: Session,
|
|
line_id: str,
|
|
payload: LineUpdateRequest,
|
|
*,
|
|
actor_user_id: str,
|
|
) -> LineSummary | None:
|
|
line = get_line_by_id(db, line_id)
|
|
if not line:
|
|
return None
|
|
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
if "name" in update_data and update_data["name"] is not None:
|
|
line.name = str(update_data["name"]).strip()
|
|
if "voltage_kv" in update_data:
|
|
line.voltage_kv = update_data["voltage_kv"]
|
|
if "tower_shape" in update_data:
|
|
line.tower_shape = _normalize_str(update_data["tower_shape"])
|
|
if "phase_sequence_json" in update_data and update_data["phase_sequence_json"] is not None:
|
|
line.phase_sequence_json = dict(update_data["phase_sequence_json"])
|
|
if "arrester_install_json" in update_data and update_data["arrester_install_json"] is not None:
|
|
line.arrester_install_json = dict(update_data["arrester_install_json"])
|
|
if "lightning_param_json" in update_data and update_data["lightning_param_json"] is not None:
|
|
line.lightning_param_json = dict(update_data["lightning_param_json"])
|
|
if "status" in update_data and update_data["status"] is not None:
|
|
line.status = str(update_data["status"])
|
|
|
|
line.update_user = actor_user_id
|
|
line.update_date = utcnow()
|
|
db.commit()
|
|
|
|
saved = get_line_by_id(db, line_id)
|
|
if not saved:
|
|
return None
|
|
|
|
tower_count = int(db.scalar(select(func.count()).select_from(LineTower).where(LineTower.line_id == line_id)) or 0)
|
|
_publish_line_change("power-lines.updated", {"action": "updated", "line_id": line_id})
|
|
return serialize_line(saved, tower_count=tower_count)
|
|
|
|
|
|
def delete_line(db: Session, line_id: str) -> tuple[bool, int]:
|
|
line = get_line_by_id(db, line_id)
|
|
if not line:
|
|
return False, 0
|
|
|
|
tower_count = int(db.scalar(select(func.count()).select_from(LineTower).where(LineTower.line_id == line_id)) or 0)
|
|
if tower_count > 0:
|
|
return False, tower_count
|
|
|
|
db.delete(line)
|
|
db.commit()
|
|
_publish_line_change("power-lines.deleted", {"action": "deleted", "line_id": line_id})
|
|
return True, 0
|
|
|
|
|
|
def list_line_towers(
|
|
db: Session,
|
|
*,
|
|
line_id: str,
|
|
keyword: str | None,
|
|
tower_type: str | None,
|
|
risk_level: str | None,
|
|
limit: int,
|
|
offset: int,
|
|
) -> LineTowerListResponse:
|
|
filters: list[Any] = [LineTower.line_id == line_id]
|
|
normalized_keyword = (keyword or "").strip()
|
|
if normalized_keyword:
|
|
like = f"%{normalized_keyword}%"
|
|
filters.append(or_(LineTower.tower_no.ilike(like), LineTower.tower_model.ilike(like)))
|
|
if tower_type:
|
|
filters.append(LineTower.tower_type == tower_type)
|
|
if risk_level:
|
|
filters.append(LineTower.risk_level == risk_level)
|
|
|
|
total = int(db.scalar(select(func.count()).select_from(LineTower).where(*filters)) or 0)
|
|
items = db.execute(
|
|
select(LineTower)
|
|
.where(*filters)
|
|
.order_by(LineTower.seq_no.asc(), LineTower.id.asc())
|
|
.offset(offset)
|
|
.limit(limit)
|
|
).scalars().all()
|
|
|
|
return LineTowerListResponse(
|
|
items=[serialize_line_tower(item) for item in items],
|
|
total=total,
|
|
)
|
|
|
|
|
|
def get_line_tower_by_id(db: Session, tower_id: str) -> LineTower | None:
|
|
return db.execute(select(LineTower).where(LineTower.id == tower_id)).scalar_one_or_none()
|
|
|
|
|
|
def create_line_tower(
|
|
db: Session,
|
|
line_id: str,
|
|
payload: LineTowerCreateRequest,
|
|
*,
|
|
actor_user_id: str,
|
|
) -> LineTowerSummary | None:
|
|
if not get_line_by_id(db, line_id):
|
|
return None
|
|
|
|
existed = db.execute(
|
|
select(LineTower).where(
|
|
LineTower.line_id == line_id,
|
|
or_(LineTower.seq_no == payload.seq_no, LineTower.tower_no == payload.tower_no.strip()),
|
|
)
|
|
).scalar_one_or_none()
|
|
if existed:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tower sequence or tower number already exists")
|
|
|
|
now = utcnow()
|
|
item = LineTower(
|
|
line_id=line_id,
|
|
seq_no=payload.seq_no,
|
|
tower_no=payload.tower_no.strip(),
|
|
tower_model=_normalize_str(payload.tower_model),
|
|
tower_type=_normalize_str(payload.tower_type),
|
|
longitude=payload.longitude,
|
|
latitude=payload.latitude,
|
|
altitude_m=payload.altitude_m,
|
|
terrain=_normalize_str(payload.terrain),
|
|
ground_resistance_ohm=payload.ground_resistance_ohm,
|
|
lightning_density=payload.lightning_density,
|
|
span_small_m=payload.span_small_m,
|
|
span_large_m=payload.span_large_m,
|
|
slope_1=payload.slope_1,
|
|
slope_2=payload.slope_2,
|
|
risk_level=_normalize_str(payload.risk_level),
|
|
circuit_geometry_json=payload.circuit_geometry_json,
|
|
lightning_result_json=payload.lightning_result_json,
|
|
raw_extra_json=payload.raw_extra_json,
|
|
create_user=actor_user_id,
|
|
update_user=actor_user_id,
|
|
create_date=now,
|
|
update_date=now,
|
|
)
|
|
db.add(item)
|
|
db.commit()
|
|
|
|
saved = get_line_tower_by_id(db, item.id)
|
|
if not saved:
|
|
return None
|
|
_publish_line_change("power-lines.towers.created", {"action": "tower_created", "line_id": line_id, "tower_id": saved.id})
|
|
return serialize_line_tower(saved)
|
|
|
|
|
|
def update_line_tower(
|
|
db: Session,
|
|
tower_id: str,
|
|
payload: LineTowerUpdateRequest,
|
|
*,
|
|
actor_user_id: str,
|
|
) -> LineTowerSummary | None:
|
|
tower = get_line_tower_by_id(db, tower_id)
|
|
if not tower:
|
|
return None
|
|
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
next_seq_no = tower.seq_no
|
|
if "seq_no" in update_data and update_data["seq_no"] is not None:
|
|
next_seq_no = int(update_data["seq_no"])
|
|
|
|
next_tower_no = tower.tower_no
|
|
if "tower_no" in update_data:
|
|
normalized_tower_no = _normalize_str(update_data["tower_no"])
|
|
if normalized_tower_no is None:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="tower_no cannot be empty")
|
|
next_tower_no = normalized_tower_no
|
|
|
|
conflict = db.execute(
|
|
select(LineTower).where(
|
|
LineTower.line_id == tower.line_id,
|
|
LineTower.id != tower.id,
|
|
or_(LineTower.seq_no == next_seq_no, LineTower.tower_no == next_tower_no),
|
|
)
|
|
).scalar_one_or_none()
|
|
if conflict:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tower sequence or tower number already exists")
|
|
|
|
for field in (
|
|
"seq_no",
|
|
"longitude",
|
|
"latitude",
|
|
"altitude_m",
|
|
"ground_resistance_ohm",
|
|
"lightning_density",
|
|
"span_small_m",
|
|
"span_large_m",
|
|
"slope_1",
|
|
"slope_2",
|
|
):
|
|
if field in update_data and update_data[field] is not None:
|
|
setattr(tower, field, update_data[field])
|
|
|
|
if "tower_no" in update_data:
|
|
tower.tower_no = next_tower_no
|
|
|
|
for field in ("tower_model", "tower_type", "terrain", "risk_level"):
|
|
if field in update_data:
|
|
setattr(tower, field, _normalize_str(update_data[field]))
|
|
|
|
if "circuit_geometry_json" in update_data and update_data["circuit_geometry_json"] is not None:
|
|
tower.circuit_geometry_json = dict(update_data["circuit_geometry_json"])
|
|
if "lightning_result_json" in update_data and update_data["lightning_result_json"] is not None:
|
|
tower.lightning_result_json = dict(update_data["lightning_result_json"])
|
|
if "raw_extra_json" in update_data and update_data["raw_extra_json"] is not None:
|
|
tower.raw_extra_json = dict(update_data["raw_extra_json"])
|
|
|
|
tower.update_user = actor_user_id
|
|
tower.update_date = utcnow()
|
|
db.commit()
|
|
|
|
saved = get_line_tower_by_id(db, tower_id)
|
|
if not saved:
|
|
return None
|
|
_publish_line_change(
|
|
"power-lines.towers.updated",
|
|
{"action": "tower_updated", "line_id": saved.line_id, "tower_id": saved.id},
|
|
)
|
|
return serialize_line_tower(saved)
|
|
|
|
|
|
def delete_line_tower(db: Session, tower_id: str) -> bool:
|
|
item = get_line_tower_by_id(db, tower_id)
|
|
if not item:
|
|
return False
|
|
|
|
line_id = item.line_id
|
|
db.delete(item)
|
|
db.commit()
|
|
_publish_line_change("power-lines.towers.deleted", {"action": "tower_deleted", "line_id": line_id, "tower_id": tower_id})
|
|
return True
|
|
|
|
|
|
def import_line_towers_from_csv(
|
|
db: Session,
|
|
*,
|
|
line: Line,
|
|
file: UploadFile,
|
|
actor_user_id: str,
|
|
) -> LineTowerImportResponse:
|
|
content = file.file.read()
|
|
if not content:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="CSV file is empty")
|
|
|
|
text = _decode_csv_bytes(content)
|
|
rows = list(csv.reader(io.StringIO(text)))
|
|
if not rows:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="CSV file has no rows")
|
|
|
|
raw_header = rows[0]
|
|
if not raw_header:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="CSV header is empty")
|
|
|
|
max_row_len = max((len(row) for row in rows[1:]), default=len(raw_header))
|
|
header = list(raw_header)
|
|
extra_headers: list[str] = []
|
|
if max_row_len > len(header):
|
|
extra_count = max_row_len - len(header)
|
|
extra_headers = [f"__extra_col_{idx}" for idx in range(1, extra_count + 1)]
|
|
header.extend(extra_headers)
|
|
|
|
stats = CsvImportStats()
|
|
existing_towers = db.execute(select(LineTower).where(LineTower.line_id == line.id)).scalars().all()
|
|
tower_by_seq = {item.seq_no: item for item in existing_towers}
|
|
tower_by_no = {item.tower_no: item for item in existing_towers}
|
|
|
|
first_data_row: dict[str, str] | None = None
|
|
|
|
for row_index, source_row in enumerate(rows[1:], start=2):
|
|
row_values = source_row + [""] * (len(header) - len(source_row))
|
|
row = {name: row_values[idx] for idx, name in enumerate(header)}
|
|
if all(not str(value).strip() for value in row.values()):
|
|
continue
|
|
|
|
if first_data_row is None:
|
|
first_data_row = row
|
|
|
|
seq_no = _parse_int(row.get("序号"))
|
|
tower_no = _normalize_str(row.get("塔号"))
|
|
if seq_no is None or not tower_no:
|
|
stats.skipped_count += 1
|
|
if stats.warnings is not None:
|
|
stats.warnings.append(f"第 {row_index} 行缺少有效的序号/塔号,已跳过")
|
|
continue
|
|
|
|
tower_from_seq = tower_by_seq.get(seq_no)
|
|
tower_from_no = tower_by_no.get(tower_no)
|
|
if tower_from_seq and tower_from_no and tower_from_seq.id != tower_from_no.id:
|
|
stats.skipped_count += 1
|
|
if stats.warnings is not None:
|
|
stats.warnings.append(f"第 {row_index} 行序号/塔号映射冲突,已跳过")
|
|
continue
|
|
|
|
tower = tower_from_seq or tower_from_no
|
|
|
|
is_created = tower is None
|
|
if tower is None:
|
|
tower = LineTower(
|
|
line_id=line.id,
|
|
seq_no=seq_no,
|
|
tower_no=tower_no,
|
|
create_user=actor_user_id,
|
|
create_date=utcnow(),
|
|
)
|
|
db.add(tower)
|
|
|
|
tower.seq_no = seq_no
|
|
tower.tower_no = tower_no
|
|
tower.tower_model = _normalize_str(row.get("杆塔模型"))
|
|
tower.tower_type = _normalize_str(row.get("直线或耐张杆塔"))
|
|
tower.longitude = _parse_float(row.get("经度"))
|
|
tower.latitude = _parse_float(row.get("纬度"))
|
|
tower.altitude_m = _parse_float(row.get("海拔m"))
|
|
tower.terrain = _normalize_str(row.get("地形"))
|
|
tower.ground_resistance_ohm = _parse_float(row.get("接地电阻"))
|
|
tower.lightning_density = _parse_float(row.get("地闪密度"))
|
|
tower.span_small_m = _parse_float(row.get("小号侧档距"))
|
|
tower.span_large_m = _parse_float(row.get("大号侧档距"))
|
|
tower.slope_1 = _parse_float(row.get("地面倾角1"))
|
|
tower.slope_2 = _parse_float(row.get("地面倾角2"))
|
|
tower.risk_level = _normalize_str(row.get("雷击风险等级"))
|
|
tower.circuit_geometry_json = _build_circuit_geometry(row)
|
|
tower.lightning_result_json = _build_lightning_result(row)
|
|
tower.raw_extra_json = _extract_extra_values(row, extra_headers)
|
|
tower.update_user = actor_user_id
|
|
tower.update_date = utcnow()
|
|
|
|
tower_by_seq[tower.seq_no] = tower
|
|
tower_by_no[tower.tower_no] = tower
|
|
|
|
if is_created:
|
|
stats.imported_count += 1
|
|
else:
|
|
stats.updated_count += 1
|
|
|
|
_apply_line_metadata_from_csv(
|
|
line,
|
|
first_data_row,
|
|
actor_user_id=actor_user_id,
|
|
warnings=stats.warnings if stats.warnings is not None else [],
|
|
)
|
|
|
|
db.commit()
|
|
tower_count = int(db.scalar(select(func.count()).select_from(LineTower).where(LineTower.line_id == line.id)) or 0)
|
|
_publish_line_change(
|
|
"power-lines.towers.imported",
|
|
{
|
|
"action": "tower_imported",
|
|
"line_id": line.id,
|
|
"imported_count": stats.imported_count,
|
|
"updated_count": stats.updated_count,
|
|
"skipped_count": stats.skipped_count,
|
|
},
|
|
)
|
|
|
|
return LineTowerImportResponse(
|
|
line=serialize_line(line, tower_count=tower_count),
|
|
imported_count=stats.imported_count,
|
|
updated_count=stats.updated_count,
|
|
skipped_count=stats.skipped_count,
|
|
warning_count=len(stats.warnings or []),
|
|
warnings=stats.warnings or [],
|
|
)
|
|
|
|
|
|
def export_line_towers_to_csv(db: Session, *, line: Line) -> tuple[str, bytes]:
|
|
towers = db.execute(
|
|
select(LineTower)
|
|
.where(LineTower.line_id == line.id)
|
|
.order_by(LineTower.seq_no.asc(), LineTower.id.asc())
|
|
).scalars().all()
|
|
|
|
headers = [
|
|
"线路编号",
|
|
"线路名称",
|
|
"电压等级",
|
|
"塔形",
|
|
"序号",
|
|
"塔号",
|
|
"杆塔模型",
|
|
"直线或耐张杆塔",
|
|
"经度",
|
|
"纬度",
|
|
"海拔m",
|
|
"接地电阻",
|
|
"地闪密度",
|
|
"小号侧档距",
|
|
"大号侧档距",
|
|
"地面倾角1",
|
|
"地面倾角2",
|
|
"地形",
|
|
"雷击风险等级",
|
|
"几何参数JSON",
|
|
"雷电参数JSON",
|
|
"额外字段JSON",
|
|
]
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(headers)
|
|
|
|
for tower in towers:
|
|
writer.writerow(
|
|
[
|
|
line.code,
|
|
line.name,
|
|
line.voltage_kv or "",
|
|
line.tower_shape or "",
|
|
tower.seq_no,
|
|
tower.tower_no,
|
|
tower.tower_model or "",
|
|
tower.tower_type or "",
|
|
_to_csv_number(tower.longitude),
|
|
_to_csv_number(tower.latitude),
|
|
_to_csv_number(tower.altitude_m),
|
|
_to_csv_number(tower.ground_resistance_ohm),
|
|
_to_csv_number(tower.lightning_density),
|
|
_to_csv_number(tower.span_small_m),
|
|
_to_csv_number(tower.span_large_m),
|
|
_to_csv_number(tower.slope_1),
|
|
_to_csv_number(tower.slope_2),
|
|
tower.terrain or "",
|
|
tower.risk_level or "",
|
|
_json_dumps_compact(tower.circuit_geometry_json or {}),
|
|
_json_dumps_compact(tower.lightning_result_json or {}),
|
|
_json_dumps_compact(tower.raw_extra_json or {}),
|
|
]
|
|
)
|
|
|
|
filename = f"{line.code}_towers_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
|
return filename, output.getvalue().encode("utf-8-sig")
|
|
|
|
|
|
def _load_tower_counts(db: Session, line_ids: list[str]) -> dict[str, int]:
|
|
if not line_ids:
|
|
return {}
|
|
rows = db.execute(
|
|
select(LineTower.line_id, func.count())
|
|
.where(LineTower.line_id.in_(line_ids))
|
|
.group_by(LineTower.line_id)
|
|
).all()
|
|
return {str(row[0]): int(row[1] or 0) for row in rows}
|
|
|
|
|
|
def _apply_line_metadata_from_csv(
|
|
line: Line,
|
|
row: dict[str, str] | None,
|
|
*,
|
|
actor_user_id: str,
|
|
warnings: list[str],
|
|
) -> None:
|
|
if not row:
|
|
return
|
|
|
|
csv_line_code = _normalize_str(row.get("线路编号"))
|
|
if csv_line_code and csv_line_code != line.code:
|
|
warnings.append(f"CSV 线路编号 {csv_line_code} 与当前线路编码 {line.code} 不一致,已保留当前线路编码")
|
|
|
|
csv_line_name = _normalize_str(row.get("线路名称"))
|
|
if csv_line_name:
|
|
line.name = csv_line_name
|
|
|
|
voltage = _parse_int(row.get("电压等级"))
|
|
if voltage is not None:
|
|
line.voltage_kv = voltage
|
|
|
|
tower_shape = _normalize_str(row.get("塔形"))
|
|
if tower_shape:
|
|
line.tower_shape = tower_shape
|
|
|
|
line.phase_sequence_json = {
|
|
"I": _normalize_str(row.get("I回相序")),
|
|
"II": _normalize_str(row.get("II回相序")),
|
|
"III": _normalize_str(row.get("III回相序")),
|
|
"IV": _normalize_str(row.get("IV回相序")),
|
|
}
|
|
line.arrester_install_json = {
|
|
"A": _normalize_str(row.get("A相是否安装避雷器")),
|
|
"B": _normalize_str(row.get("B相是否安装避雷器")),
|
|
"C": _normalize_str(row.get("C相是否安装避雷器")),
|
|
}
|
|
line.lightning_param_json = {
|
|
"电角度": _parse_float(row.get("电角度")),
|
|
"雷电流幅值a": _parse_float(row.get("雷电流幅值a")),
|
|
"雷电流幅值b": _parse_float(row.get("雷电流幅值b")),
|
|
}
|
|
line.update_user = actor_user_id
|
|
line.update_date = utcnow()
|
|
|
|
|
|
def _build_circuit_geometry(row: dict[str, str]) -> dict[str, Any]:
|
|
def value(key: str) -> float | None:
|
|
return _parse_float(row.get(key))
|
|
|
|
return {
|
|
"I": {
|
|
"phase_spacing_m": {
|
|
"upper": value("I回上相中距m"),
|
|
"middle": value("I回中相中距m"),
|
|
"lower": value("I回下相中距m"),
|
|
},
|
|
"phase_height_m": {
|
|
"upper": value("I回上相高度m"),
|
|
"middle": value("I回中相高度m"),
|
|
"lower": value("I回下相高度m"),
|
|
},
|
|
},
|
|
"II": {
|
|
"phase_spacing_m": {
|
|
"upper": value("II回上相中距m"),
|
|
"middle": value("II回中相中距m"),
|
|
"lower": value("II回下相中距m"),
|
|
},
|
|
"phase_height_m": {
|
|
"upper": value("II回上相高度m"),
|
|
"middle": value("II回中相高度m"),
|
|
"lower": value("II回下相高度m"),
|
|
},
|
|
},
|
|
"III": {
|
|
"phase_spacing_m": {
|
|
"upper": value("III回上相中距m"),
|
|
"middle": value("III回中相中距m"),
|
|
"lower": value("III回下相中距m"),
|
|
},
|
|
"phase_height_m": {
|
|
"upper": value("III回上相高度m"),
|
|
"middle": value("III回中相高度m"),
|
|
"lower": value("III回下相高度m"),
|
|
},
|
|
},
|
|
"IV": {
|
|
"phase_spacing_m": {
|
|
"upper": value("IV回上相中距m"),
|
|
"middle": value("IV回中相中距m"),
|
|
"lower": value("IV回下相中距m"),
|
|
},
|
|
"phase_height_m": {
|
|
"upper": value("IV回上相高度m"),
|
|
"middle": value("IV回中相高度m"),
|
|
"lower": value("IV回下相高度m"),
|
|
},
|
|
},
|
|
"insulator_length_mm": _parse_float(row.get("绝缘子串长度mm")),
|
|
"tower_height_m": _parse_float(row.get("杆塔呼高m")),
|
|
"lightning_wire": {
|
|
"left_mid_distance_m": _parse_float(row.get("左避雷中距m")),
|
|
"right_mid_distance_m": _parse_float(row.get("右避雷中距m")),
|
|
"height_m": _parse_float(row.get("避雷线高度m")),
|
|
},
|
|
}
|
|
|
|
|
|
def _build_lightning_result(row: dict[str, str]) -> dict[str, Any]:
|
|
return {
|
|
"counterstroke_indicator": _parse_float(row.get("绕击反击")),
|
|
"counterstroke_withstand_ka": _parse_float(row.get("反击耐雷水平kA")),
|
|
"counterstroke_trip_rate": _parse_float(row.get("反击跳闸率(次/100km.a)")),
|
|
"shielding_withstand_ka": _parse_float(row.get("绕击耐雷水平kA")),
|
|
"shielding_trip_rate": _parse_float(row.get("绕击跳闸率(次/100km.a)")),
|
|
"risk_level": _normalize_str(row.get("雷击风险等级")),
|
|
}
|
|
|
|
|
|
def _extract_extra_values(row: dict[str, str], extra_headers: list[str]) -> dict[str, Any]:
|
|
result: dict[str, Any] = {}
|
|
for key in extra_headers:
|
|
value = _normalize_str(row.get(key))
|
|
if value is not None:
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def _decode_csv_bytes(content: bytes) -> str:
|
|
for encoding in CSV_ENCODINGS:
|
|
try:
|
|
return content.decode(encoding)
|
|
except UnicodeDecodeError:
|
|
continue
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="CSV encoding is not supported")
|
|
|
|
|
|
def _normalize_str(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip()
|
|
if not text:
|
|
return None
|
|
if text in {"-1", "-1.0"}:
|
|
return None
|
|
return text
|
|
|
|
|
|
def _parse_float(value: Any) -> float | None:
|
|
normalized = _normalize_str(value)
|
|
if normalized is None:
|
|
return None
|
|
try:
|
|
return float(normalized)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _parse_int(value: Any) -> int | None:
|
|
normalized = _normalize_str(value)
|
|
if normalized is None:
|
|
return None
|
|
try:
|
|
return int(float(normalized))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _json_dumps_compact(payload: dict[str, Any]) -> str:
|
|
import json
|
|
|
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _to_csv_number(value: float | None) -> str:
|
|
if value is None:
|
|
return ""
|
|
if float(value).is_integer():
|
|
return str(int(value))
|
|
return f"{value:.6f}".rstrip("0").rstrip(".")
|
|
|
|
|
|
def _publish_line_change(event_name: str, payload: dict[str, Any]) -> None:
|
|
_fire_and_forget(
|
|
publish_topic(
|
|
LINE_TOPIC,
|
|
name=event_name,
|
|
payload=payload,
|
|
requires_refetch=["/api/v1/lines"],
|
|
dedupe_key=f"{event_name}:{payload.get('line_id', 'unknown')}",
|
|
)
|
|
)
|
|
|
|
|
|
def _fire_and_forget(coro: object) -> None:
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
return
|
|
loop.create_task(coro)
|