"""Estimate a scenario's time-cumulative concentration coverage before output filtering.""" from datetime import datetime, timedelta from math import isfinite from typing import Any from app.services.node_coverage import MAX_COVERAGE_NODES, generate_node_coverage def _sample_times(start: datetime, duration: int, metadata: dict) -> list[datetime]: explicit = metadata.get("sample_times") if explicit is not None: times = [datetime.fromisoformat(value) for value in explicit] else: offset, step, count = (metadata.get(key) for key in ("report_start", "report_step", "num_periods")) if (any(type(value) is not int for value in (offset, step, count)) or offset < 0 or step <= 0 or not 0 < count <= 86401): raise ValueError("missing or invalid report grid") times = [start + timedelta(seconds=offset + index * step) for index in range(count)] if (not times or len(times) > 86401 or any(at.tzinfo is None for at in times) or any(not start <= at <= start + timedelta(seconds=duration) for at in times) or any(left >= right for left, right in zip(times, times[1:]))): raise ValueError("invalid simulation sample times") return times def estimate_contaminant_impact(network: str, start: datetime, duration: int, simulation: dict[str, Any], *, threshold_mg_l: float = 0, model_only: bool = False) -> dict[str, Any]: """Use every native node and report sample; never infer propagation from requested nodes.""" impact: dict[str, Any] = { "status": "unavailable", "phase": "after_contamination_before_intervention", "window_start": start.isoformat(), "window_end": (start + timedelta(seconds=duration)).isoformat(), "threshold_mg_l": threshold_mg_l, "threshold_operator": ">", "concentration_unit": "mg/L", "threshold_purpose": "scenario_display_not_drinking_water_standard", "model_temporality": "current_model", "quantity": "total_model_concentration", "boundary_source": "model_only_no_live_scada" if model_only else "model_with_start_time_scada", "boundary": "给定源浓度、模型初始水质及反应条件下,整个模拟时段内超过展示阈值的节点覆盖;" "首达时间为首个超过阈值的报告采样时刻。不是污染确诊、源独立贡献、现场污染边界或饮用水合格判定。", } output = {"impact": impact} unit = simulation.get("units", {}).get("quality") factor = {"mgl": 1.0, "mg/l": 1.0, "ugl": 0.001, "ug/l": 0.001}.get( str(unit).strip().lower().replace("μ", "u").replace("µ", "u")) impact["input_concentration_unit"] = unit if factor is None: impact["reason"] = "unsupported_or_missing_concentration_unit" return output try: times = _sample_times(start, duration, simulation.get("times", {})) except (TypeError, ValueError, OverflowError): impact["reason"] = "simulation_has_no_verified_sample_times" return output rows = simulation.get("node_results", []) ids = [str(row["node"]) for row in rows] if not ids or len(ids) != len(set(ids)): impact["reason"] = "missing_or_duplicate_simulation_nodes" return output affected, unavailable, incomplete = [], [], [] for row in rows: node = str(row["node"]) samples = row.get("result") or [] if len(samples) > len(times): impact["reason"] = "simulation_sample_count_mismatch" return output valid = [] for at, sample in zip(times, samples): value = sample.get("quality") if isinstance(sample, dict) else None if isinstance(value, bool) or value is None: continue try: concentration = float(value) * factor except (TypeError, ValueError, OverflowError): continue if isfinite(concentration) and concentration >= 0: valid.append((at, concentration)) if not valid: unavailable.append(node) continue if len(valid) != len(times): incomplete.append(node) exceedances = [(at, value) for at, value in valid if value > threshold_mg_l] if exceedances: peak_at, peak = max(valid, key=lambda item: item[1]) affected.append({"node_id": node, "first_exceedance_sample_at": exceedances[0][0].isoformat(), "peak_mg_l": peak, "peak_at": peak_at.isoformat(), "valid_sample_count": len(valid), "complete": len(valid) == len(times)}) impact.update(status="partial" if unavailable or incomplete else "ready", node_count=len(ids), report_sample_count=len(times), reported_from=times[0].isoformat(), reported_through=times[-1].isoformat(), unavailable_node_ids=unavailable, incomplete_node_ids=incomplete, affected_node_count=len(affected), nodes=affected, affected_node_ids=[item["node_id"] for item in affected]) if len(unavailable) == len(ids): impact.update(status="unavailable", reason="no_valid_concentration_samples") return output if len(affected) > MAX_COVERAGE_NODES: impact.update(coverage_status="unavailable", coverage_reason="affected_nodes_exceed_10000_coverage_limit") return output geometry = generate_node_coverage(network, [{ "group_id": "contaminant_exposure", "title": f"模拟浓度>{threshold_mg_l:g} mg/L的累计节点范围", "node_ids": impact["affected_node_ids"], }]) impact["coverage_status"] = geometry["coverage"]["status"] geometry["coverage"]["boundary"] = impact["boundary"] for feature in geometry["map_payload"]["features"]: feature["properties"].update(impact_phase=impact["phase"], concentration_unit="mg/L", threshold_mg_l=threshold_mg_l, model_estimate=True, window_start=impact["window_start"], window_end=impact["window_end"]) if geometry["coverage"]["status"] == "partial": impact["status"] = "partial" output.update(geometry) return output