equivalence.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. """主通风机双机报告参数、性能和切换状态比较;所有指标由受控函数计算。"""
  2. from bisect import bisect_right
  3. from collections import defaultdict
  4. from dataclasses import dataclass
  5. from datetime import datetime
  6. import hashlib
  7. import json
  8. import math
  9. import re
  10. from statistics import mean, pstdev
  11. import unicodedata
  12. from algorithms.main_fan.analysis import LOCAL_ZONE, UNITS, fan_numbers, local_time, same_mine
  13. from algorithms.main_fan.models import FactModel, ScreeningInput, SwitchRecord
  14. from pydantic import Field, model_validator
  15. TOOL_VERSION = "main-fan-pair-1.2"
  16. SKILL_VERSION = "main-fan-equivalent-capability-assessment-1.2"
  17. class CurvePolicy(FactModel):
  18. minimum_points: int = Field(default=4, ge=2)
  19. minimum_common_flow_coverage: float = Field(default=.8, gt=0, le=1)
  20. mean_pressure_deviation_percent: float = Field(default=5, ge=0)
  21. maximum_pressure_deviation_percent: float = Field(default=10, ge=0)
  22. maximum_power_deviation_percent: float = Field(default=10, ge=0)
  23. efficiency_difference_points: float = Field(default=5, ge=0)
  24. operating_flow_deviation_percent: float = Field(default=10, ge=0)
  25. operating_pressure_deviation_percent: float = Field(default=10, ge=0)
  26. class SwitchPolicy(FactModel):
  27. maximum_switch_interval_minutes: float = Field(default=10, ge=0)
  28. window_minutes: int = Field(default=30, ge=1)
  29. transition_minutes: int = Field(default=5, ge=0)
  30. minimum_completeness: float = Field(default=.9, gt=0, le=1)
  31. sample_interval_seconds: int = Field(default=60, ge=1)
  32. maximum_cv: float = Field(default=.05, ge=0)
  33. flow_deviation_percent: float = Field(default=10, ge=0)
  34. pressure_deviation_percent: float = Field(default=10, ge=0)
  35. minimum_duration_minutes: int = Field(default=10, ge=1)
  36. maximum_recovery_minutes: int = Field(default=30, ge=1)
  37. class SeverityPolicy(FactModel):
  38. # 严重阈值由业务另行配置,不把刚超过正常线直接当成严重异常。
  39. abnormal_switch_interval_minutes: float | None = Field(default=None, gt=0)
  40. abnormal_flow_deviation_percent: float | None = Field(default=None, gt=0)
  41. abnormal_parameter_fields: list[str] = Field(default_factory=list)
  42. class RulePolicy(FactModel):
  43. rule_version: str = Field(min_length=1)
  44. threshold_version: str = Field(min_length=1)
  45. curve: CurvePolicy = Field(default_factory=CurvePolicy)
  46. switch: SwitchPolicy = Field(default_factory=SwitchPolicy)
  47. severity: SeverityPolicy = Field(default_factory=SeverityPolicy)
  48. @model_validator(mode="after")
  49. def separate_severe_limits(self):
  50. severe = self.severity
  51. if (severe.abnormal_switch_interval_minutes is not None
  52. and severe.abnormal_switch_interval_minutes <= self.switch.maximum_switch_interval_minutes):
  53. raise ValueError("严重切换间隔阈值必须高于正常切换间隔上限")
  54. if (severe.abnormal_flow_deviation_percent is not None
  55. and severe.abnormal_flow_deviation_percent <= self.switch.flow_deviation_percent):
  56. raise ValueError("严重风量差异阈值必须高于正常风量差异上限")
  57. return self
  58. def model_key(value):
  59. value = unicodedata.normalize("NFKC", value).casefold()
  60. return re.sub(r"[\s\-_.]", "", value.replace("№", "no").replace("nº", "no"))
  61. def rated_power(report):
  62. """2×185 表达的是两台185kW电动机,受控计算总功率并保留组成。"""
  63. raw = unicodedata.normalize("NFKC", report.rated_power_raw).lower()
  64. raw = re.sub(r"\s|kw|千瓦", "", raw).replace("×", "*").replace("x", "*")
  65. parts = None
  66. if re.fullmatch(r"[1-9]\d?\*\d+(?:\.\d+)?", raw):
  67. count, value = raw.split("*")
  68. parts = [float(value)] * int(count)
  69. elif re.fullmatch(r"\d+(?:\.\d+)?(?:\+\d+(?:\.\d+)?)+", raw):
  70. parts = [float(value) for value in raw.split("+")]
  71. elif re.fullmatch(r"\d+(?:\.\d+)?", raw):
  72. parts = [float(raw)]
  73. elif not raw and report.rated_power_kw:
  74. parts = [report.rated_power_kw]
  75. if not parts or any(p <= 0 for p in parts):
  76. return None
  77. total = sum(parts)
  78. if report.rated_power_kw is not None and abs(total-report.rated_power_kw) > .001:
  79. return None
  80. return {"raw": report.rated_power_raw or f"{total:g} kW", "components_kw": parts, "total_kw": total}
  81. def evidence_id(evidence):
  82. content = json.dumps(evidence.model_dump(mode="json"), sort_keys=True, ensure_ascii=False)
  83. return "e-" + hashlib.sha256(content.encode()).hexdigest()[:16]
  84. def envelope(value, policy, evidence=()):
  85. return {**value, "evidence_ids": sorted({evidence_id(e) for e in evidence if e.source or e.excerpt}),
  86. "rule_version": policy.rule_version, "confidence": None, "warnings": value.get("warnings", [])}
  87. @dataclass
  88. class Dataset:
  89. data: ScreeningInput
  90. fans: list[dict]
  91. policy: RulePolicy
  92. warnings: list[str]
  93. def normalize_and_validate_fan_data(data, policy, fan_names=None):
  94. fan_names = dict(fan_names or {})
  95. fan_names.update({d.device_code: d.name for d in data.devices if same_mine(d, data)})
  96. if data.fan_id and data.fan_name:
  97. fan_names[data.fan_id] = data.fan_name
  98. now = local_time(data.evaluation_time or datetime.now(LOCAL_ZONE))
  99. groups = defaultdict(list)
  100. for report in data.reports:
  101. if same_mine(report, data) and (not report.inspected_on or report.inspected_on <= now.date()):
  102. key = tuple(sorted(fan_numbers(report.fan_name)))
  103. if len(key) == 1:
  104. groups[key].append(report)
  105. selected = []
  106. warnings = []
  107. if data.fan_pair:
  108. if len(data.fan_pair) != 2 or len(set(data.fan_pair)) != 2:
  109. raise ValueError("fan_pair必须是两个不同的风机标识")
  110. for fan_id in data.fan_pair:
  111. key = tuple(sorted(fan_numbers(fan_names.get(fan_id, ""))))
  112. candidates = groups.get(key, [])
  113. if candidates:
  114. selected.append((fan_id, max(candidates, key=lambda r: r.inspected_on.isoformat() if r.inspected_on else "")))
  115. elif len(groups) == 2:
  116. for key in sorted(groups):
  117. report = max(groups[key], key=lambda r: r.inspected_on.isoformat() if r.inspected_on else "")
  118. known = [code for code, name in fan_names.items() if tuple(sorted(fan_numbers(name))) == key]
  119. fan_id = known[0] if len(known) == 1 else "report:" + report.report_id
  120. selected.append((fan_id, report))
  121. else:
  122. key = tuple(sorted(fan_numbers(data.fan_name)))
  123. candidates = groups.get(key, [])
  124. if candidates:
  125. selected = [(data.fan_id or "report:"+candidates[0].report_id,
  126. max(candidates, key=lambda r: r.inspected_on.isoformat() if r.inspected_on else ""))]
  127. if len(groups) > 2:
  128. warnings.append("本矿井有多组风机资料,未自动选择任意两台进行比较。")
  129. if len({r.report_id for _, r in selected}) != len(selected):
  130. raise ValueError("两台风机身份指向同一份报告")
  131. fans = [{"fan_id": code, "fan_name": report.fan_name, "report": report, "rated_power": rated_power(report)} for code, report in selected]
  132. # 没有性能报告时,只有实际设备记录/明确配置的名称才构造身份,仍不猜主备角色。
  133. if not fans:
  134. ids = data.fan_pair or ([data.fan_id] if data.fan_id else [])
  135. fans = [{"fan_id": code, "fan_name": fan_names[code], "report": None, "rated_power": None}
  136. for code in ids if code in fan_names]
  137. return Dataset(data, fans, policy, warnings)
  138. def compare_basic_parameters(dataset):
  139. fans, data, policy = dataset.fans, dataset.data, dataset.policy
  140. rows = []
  141. fields = [("model", "风机型号", True), ("motor_model", "电动机型号", False),
  142. ("rated_speed_rpm", "额定转速", False), ("rated_voltage_raw", "额定电压", False),
  143. ("impeller_diameter_m", "叶轮直径", False), ("manufacturer", "风机制造商", False),
  144. ("structure_type", "结构类型", False), ("ventilation_mode", "通风方式", False),
  145. ("rated_flow_range_m3_min", "额定风量范围", False), ("rated_pressure_range_pa", "额定风压范围", False)]
  146. evidence = []
  147. if len(fans) == 2 and all(f["report"] for f in fans):
  148. reports = [f["report"] for f in fans]
  149. for field, label, core in fields:
  150. values = [getattr(r, field) for r in reports]
  151. if not all(v is not None and v != "" and v != [] for v in values):
  152. continue
  153. match = model_key(values[0]) == model_key(values[1]) if isinstance(values[0], str) else values[0] == values[1]
  154. used = [r.metadata_evidence.get(field, r.evidence) for r in reports]
  155. evidence.extend(used)
  156. rows.append({"field": field, "label": label, "values": values, "status": "match" if match else "mismatch",
  157. "core": core, "evidence_ids": [evidence_id(e) for e in used]})
  158. powers = [f["rated_power"] for f in fans]
  159. if all(powers):
  160. used = [r.metadata_evidence.get("rated_power_raw", r.metadata_evidence.get("rated_power_kw", r.evidence)) for r in reports]
  161. evidence.extend(used)
  162. rows.append({"field": "rated_power", "label": "电动机额定功率", "values": powers,
  163. "status": "match" if powers[0]["total_kw"] == powers[1]["total_kw"] else "mismatch",
  164. "core": True, "evidence_ids": [evidence_id(e) for e in used]})
  165. devices = [d for d in data.devices if same_mine(d, data) and d.device_code in {f['fan_id'] for f in fans}]
  166. standby = [d for d in devices if d.fan_role == "standby"]
  167. availability = None
  168. if len(standby) == 1 and standby[0].availability_status != "unknown" and standby[0].observed_at:
  169. age = (local_time(data.evaluation_time or datetime.now(LOCAL_ZONE))-local_time(standby[0].observed_at)).total_seconds()
  170. if -120 <= age <= 600:
  171. availability = {"fan_id": standby[0].device_code, "status": standby[0].availability_status,
  172. "evidence_ids": [evidence_id(standby[0].evidence)]}
  173. evidence.append(standby[0].evidence)
  174. core = [r for r in rows if r["core"]]
  175. status = "fail" if any(r["status"] == "mismatch" for r in rows) else "pass" if len(core) == 2 else "insufficient"
  176. return envelope({"status": status, "rows": rows, "backup_availability": availability}, policy, evidence)
  177. def _curve_points(rows):
  178. by_flow = {}
  179. for row in rows:
  180. value = (row.static_pressure_pa, row.shaft_power_kw,
  181. row.reported_efficiency_pct if row.reported_efficiency_pct is not None else row.flow_m3_s*row.static_pressure_pa/(10*row.shaft_power_kw))
  182. if row.flow_m3_s in by_flow and by_flow[row.flow_m3_s] != value:
  183. raise ValueError("同一风量存在不同性能值,不能任意覆盖")
  184. by_flow[row.flow_m3_s] = value
  185. return sorted(by_flow.items())
  186. def _interpolate(points, q):
  187. if q < points[0][0] or q > points[-1][0]:
  188. raise ValueError("禁止超出原始风量范围外推")
  189. if q == points[-1][0]:
  190. return points[-1][1]
  191. index = max(0, bisect_right([p[0] for p in points], q)-1)
  192. a, b = points[index], points[index+1]
  193. weight = (q-a[0])/(b[0]-a[0])
  194. return tuple(x+(y-x)*weight for x, y in zip(a[1], b[1]))
  195. def _relative(a, b):
  196. return abs(b-a)/abs(a)*100 if a else (0.0 if b == 0 else None)
  197. def analyze_fan_performance_curve(dataset):
  198. fans, policy = dataset.fans, dataset.policy
  199. results, warnings, used = [], [], []
  200. if len(fans) != 2 or not all(f["report"] for f in fans):
  201. return envelope({"status": "insufficient", "results": []}, policy)
  202. groups = []
  203. for fan in fans:
  204. current = defaultdict(list)
  205. report = fan["report"]
  206. for point in report.points:
  207. if point.condition == "measured" and point.frequency_hz:
  208. current[(point.frequency_hz, point.blade_angle_deg if point.blade_angle_deg is not None else report.blade_angle_deg)].append(point)
  209. groups.append(current)
  210. for key in sorted(set(groups[0]) & set(groups[1]), key=lambda k: (k[0], k[1] is None, k[1] or 0)):
  211. rows = [g[key] for g in groups]
  212. try:
  213. a, b = [_curve_points(values) for values in rows]
  214. except ValueError as exc:
  215. warnings.append(f"{key[0]:g}Hz:{exc}")
  216. continue
  217. if min(len(a), len(b)) < policy.curve.minimum_points:
  218. continue
  219. low, high = max(a[0][0], b[0][0]), min(a[-1][0], b[-1][0])
  220. spans = [points[-1][0]-points[0][0] for points in (a, b)]
  221. coverage = min((high-low)/span for span in spans) if high > low and all(spans) else 0
  222. if coverage < policy.curve.minimum_common_flow_coverage:
  223. warnings.append(f"{key[0]:g}Hz共同风量区间覆盖不足。")
  224. continue
  225. grid = sorted({low, high, *(q for points in (a, b) for q, _ in points if low < q < high)})
  226. samples = [(q, _interpolate(a, q), _interpolate(b, q)) for q in grid]
  227. if any(left[0] <= 0 or left[1] <= 0 for _, left, _ in samples):
  228. warnings.append(f"{key[0]:g}Hz基准静压或功率不能用于相对偏差。")
  229. continue
  230. pressure_pct = [_relative(left[0], right[0]) for _, left, right in samples]
  231. power_pct = [_relative(left[1], right[1]) for _, left, right in samples]
  232. integrated_pct = 0
  233. for i in range(len(samples)-1):
  234. q0,left0,right0=samples[i]
  235. q1,left1,right1=samples[i+1]
  236. d0,d1=right0[0]-left0[0],right1[0]-left1[0]
  237. # |线性差值/线性基准|在零交叉处拆分后解析积分,避免网格密度影响均值。
  238. segments=[(left0[0],left1[0],d0,d1,q1-q0)]
  239. if d0*d1<0:
  240. weight=-d0/(d1-d0)
  241. pressure=left0[0]+(left1[0]-left0[0])*weight
  242. segments=[(left0[0],pressure,d0,0,(q1-q0)*weight),(pressure,left1[0],0,d1,(q1-q0)*(1-weight))]
  243. for p0,p1,delta0,delta1,span in segments:
  244. slope=p1-p0
  245. if abs(slope)<1e-9:
  246. integral=(delta0+delta1)/(2*p0)
  247. else:
  248. ratio=(delta1-delta0)/slope
  249. integral=ratio+(delta0-ratio*p0)/slope*math.log(p1/p0)
  250. integrated_pct += abs(integral)*span*100
  251. integrated_pct /= high-low
  252. integrated_square = 0
  253. for i in range(len(samples)-1):
  254. d0, d1 = samples[i][2][0]-samples[i][1][0], samples[i+1][2][0]-samples[i+1][1][0]
  255. integrated_square += (samples[i+1][0]-samples[i][0])*(d0*d0+d0*d1+d1*d1)/3
  256. metrics = {"mean_pressure_deviation_percent": integrated_pct,
  257. "maximum_pressure_deviation_percent": max(pressure_pct),
  258. "pressure_rmse_pa": math.sqrt(integrated_square/(high-low)),
  259. "maximum_power_deviation_percent": max(power_pct),
  260. "efficiency_difference_points": max(abs(left[2]-right[2]) for _, left, right in samples)}
  261. operating = [[r for r in values if "运行工况" in r.label] for values in rows]
  262. operating_result = None
  263. if all(len(values) == 1 for values in operating):
  264. left, right = [values[0] for values in operating]
  265. metrics.update(operating_flow_deviation_percent=_relative(left.flow_m3_s, right.flow_m3_s),
  266. operating_pressure_deviation_percent=_relative(left.static_pressure_pa, right.static_pressure_pa))
  267. operating_result = [{"flow_m3_min": r.flow_m3_s*60, "pressure_pa": r.static_pressure_pa,
  268. "shaft_power_kw": r.shaft_power_kw, "input_power_kw": r.input_power_kw,
  269. "efficiency_pct": r.reported_efficiency_pct if r.reported_efficiency_pct is not None else r.flow_m3_s*r.static_pressure_pa/(10*r.shaft_power_kw)} for r in (left, right)]
  270. thresholds = policy.curve.model_dump()
  271. exceeded = [name for name, value in metrics.items() if value is not None and name in thresholds and value > thresholds[name]]
  272. comparable = key[1] is not None
  273. evidence = [r.evidence for values in rows for r in values]
  274. used.extend(evidence)
  275. results.append({"frequency_hz": key[0], "blade_angle_deg": key[1],
  276. "comparability": "same_angle_frequency" if comparable else "frequency_only",
  277. "status": ("fail" if exceeded else "pass") if comparable else "informational",
  278. "common_flow_range_m3_min": [low*60, high*60], "common_flow_coverage": coverage,
  279. "metrics": metrics, "thresholds": thresholds, "exceeded": exceeded,
  280. "operating_points": operating_result, "fit_method": "piecewise_linear", "extrapolation_used": False,
  281. "curve_samples": [{"flow_m3_min": q*60, "fan_1": list(left), "fan_2": list(right)} for q, left, right in samples],
  282. "evidence_ids": sorted({evidence_id(e) for e in evidence})})
  283. formal = [r for r in results if r["comparability"] == "same_angle_frequency"]
  284. status = "fail" if any(r["status"] == "fail" for r in formal) else "pass" if formal else "insufficient"
  285. return envelope({"status": status, "results": results, "warnings": warnings}, policy, used)
  286. def _state_groups(dataset):
  287. data, ids = dataset.data, {f["fan_id"] for f in dataset.fans}
  288. latest = local_time(data.evaluation_time or datetime.now(LOCAL_ZONE))
  289. groups = defaultdict(dict)
  290. conflicting = set()
  291. for record in data.fan_state_records:
  292. if same_mine(record, data) and record.fan_id in ids and record.complete_fan_state and record.event_type in {"state", "start_stop"}:
  293. at = local_time(record.observed_at)
  294. if at > latest:
  295. continue
  296. prior = groups[at].get(record.fan_id)
  297. if prior and prior.state != record.state:
  298. conflicting.add(at)
  299. groups[at][record.fan_id] = record
  300. return [(at, records if at not in conflicting and set(records) == ids and
  301. all(r.state != 'unknown' for r in records.values()) else None)
  302. for at, records in sorted(groups.items())]
  303. def _events(dataset):
  304. """整机同步状态允许经过双停过渡;缺测、冲突或未知状态切断事件链。"""
  305. data, ids = dataset.data, {f["fan_id"] for f in dataset.fans}
  306. if len(ids) != 2:
  307. return []
  308. latest = local_time(data.evaluation_time or datetime.now(LOCAL_ZONE))
  309. events = [e for e in data.switch_records if same_mine(e, data) and local_time(e.switched_at) <= latest and
  310. {e.from_fan_id, e.to_fan_id} == ids and e.from_fan_id != e.to_fan_id]
  311. previous, origin, stopped_at, started_at = None, None, None, None
  312. maximum_gap = dataset.policy.switch.sample_interval_seconds * 2
  313. groups = _state_groups(dataset)
  314. for at, records in groups:
  315. if records is None:
  316. previous, origin, stopped_at, started_at = None, None, None, None
  317. continue
  318. if previous and (at-previous[0]).total_seconds() > maximum_gap:
  319. previous, origin, stopped_at, started_at = None, None, None, None
  320. running = [r.fan_id for r in records.values() if r.state == "running"]
  321. if origin and previous:
  322. destination = next(iter(ids - {origin}))
  323. prior = previous[1]
  324. if prior[origin].state == 'running' and records[origin].state == 'stopped':
  325. stopped_at = at
  326. if prior[destination].state == 'stopped' and records[destination].state == 'running':
  327. started_at = at
  328. if running == [destination] and stopped_at is not None and started_at is not None:
  329. begin, complete = min(stopped_at, started_at), max(stopped_at, started_at)
  330. matching = [e for e in events if e.from_fan_id == origin and e.to_fan_id == destination and
  331. begin.timestamp()-maximum_gap <= local_time(e.switched_at).timestamp() <= complete.timestamp()+maximum_gap]
  332. if not matching:
  333. events.append(SwitchRecord(mine_code=data.mine_code, mine_name=data.mine_name,
  334. event_id="state-"+hashlib.sha256(str(at).encode()).hexdigest()[:12], switched_at=complete,
  335. from_fan_id=origin, to_fan_id=destination, from_stopped_at=stopped_at,
  336. to_started_at=started_at, evidence=records[destination].evidence))
  337. elif len(matching) == 1 and matching[0].from_stopped_at is None and matching[0].to_started_at is None:
  338. index = events.index(matching[0])
  339. events[index] = matching[0].model_copy(update={'switched_at':complete,
  340. 'from_stopped_at':stopped_at,'to_started_at':started_at})
  341. if len(running) == 1:
  342. origin, stopped_at, started_at = running[0], None, None
  343. previous = (at, records)
  344. return sorted(events, key=lambda e: local_time(e.switched_at))
  345. def _window(series, start, end, policy, latest=math.inf):
  346. slots = defaultdict(lambda:defaultdict(list))
  347. for sample in series.samples:
  348. at = local_time(sample.observed_at).timestamp()
  349. if start <= at < end and at <= latest and sample.status in {"normal", "正常", "online", "在线"} and sample.value >= 0:
  350. slot = int((at-start)//policy.sample_interval_seconds)
  351. slots[slot][at].append(sample.value*UNITS[series.metric][series.unit])
  352. expected = max(1, math.ceil((end-start)/policy.sample_interval_seconds))
  353. # 同一时刻冲突值无效;不能用重复/密集回补样本虚增完整率。
  354. values = [mean(values[0] for values in samples.values()) for samples in slots.values()
  355. if all(len(set(values))==1 for values in samples.values())]
  356. completeness = len(values)/expected
  357. if not values or completeness < policy.minimum_completeness:
  358. return None
  359. average = mean(values)
  360. cv = pstdev(values)/abs(average) if average else (0 if all(v == 0 for v in values) else math.inf)
  361. if cv > policy.maximum_cv:
  362. return None
  363. return {"start": datetime.fromtimestamp(start, LOCAL_ZONE).isoformat(), "end": datetime.fromtimestamp(end, LOCAL_ZONE).isoformat(),
  364. "mean": average, "std": pstdev(values), "min": min(values), "max": max(values),
  365. "sample_count": len(values), "completeness": completeness}
  366. def _over_limit(value, limit):
  367. return value > limit and not math.isclose(value, limit, rel_tol=0, abs_tol=1e-9)
  368. def _switch_timing(event, dataset):
  369. limit = dataset.policy.switch.maximum_switch_interval_minutes
  370. result = {'status':'insufficient','from_stopped_at':None,'to_started_at':None,
  371. 'interval_minutes':None,'overlap_minutes':None,'limit_minutes':limit}
  372. if event.from_stopped_at is None or event.to_started_at is None:
  373. return result
  374. stop, start = local_time(event.from_stopped_at), local_time(event.to_started_at)
  375. latest = local_time(dataset.data.evaluation_time or datetime.now(LOCAL_ZONE))
  376. if max(stop, start) > latest:
  377. return result
  378. # 明示切换记录与同一时刻的整机状态冲突时,不用任一来源静默覆盖另一来源。
  379. for fan_id, at, expected in ((event.from_fan_id, stop, 'stopped'), (event.to_fan_id, start, 'running')):
  380. states = {r.state for r in dataset.data.fan_state_records if same_mine(r, dataset.data) and
  381. r.fan_id == fan_id and r.complete_fan_state and r.event_type in {'state','start_stop'} and local_time(r.observed_at) == at}
  382. if states and states != {expected}:
  383. return result
  384. interval = max(0., (start-stop).total_seconds()/60)
  385. return {**result,'status':'fail' if _over_limit(interval, limit) else 'pass',
  386. 'from_stopped_at':stop.isoformat(),'to_started_at':start.isoformat(),
  387. 'interval_minutes':interval,'overlap_minutes':max(0.,(stop-start).total_seconds()/60)}
  388. def _switch_chart_data(dataset, event, series, start, end):
  389. ids = (event.from_fan_id, event.to_fan_id)
  390. state_curves = {fan_id:[] for fan_id in ids}
  391. for at, records in _state_groups(dataset):
  392. if records and start <= at.timestamp() < end:
  393. for fan_id in ids:
  394. state_curves[fan_id].append({'observed_at':at.isoformat(),'value':1 if records[fan_id].state == 'running' else 0})
  395. flow_curves = []
  396. latest = local_time(dataset.data.evaluation_time or datetime.now(LOCAL_ZONE)).timestamp()
  397. for fan_id in ids:
  398. candidates = [s for s in series if s.fan_id == fan_id and s.metric == 'flow']
  399. if len(candidates) != 1:
  400. continue
  401. source, samples = candidates[0], defaultdict(list)
  402. for sample in source.samples:
  403. at = local_time(sample.observed_at)
  404. if start <= at.timestamp() < end and at.timestamp() <= latest and sample.value >= 0 and sample.status in {'normal','正常','online','在线'}:
  405. samples[at].append(sample.value*UNITS['flow'][source.unit]*60)
  406. flow_curves.append({'fan_id':fan_id,'unit':'m3/min','samples':[{'observed_at':at.isoformat(),'value':values[0]}
  407. for at, values in sorted(samples.items()) if len(set(values)) == 1]})
  408. return {'state_curves':[{'fan_id':fan_id,'samples':samples} for fan_id,samples in state_curves.items() if samples],
  409. 'flow_curves':flow_curves,'sample_interval_seconds':dataset.policy.switch.sample_interval_seconds,
  410. 'synthetic':event.evidence.method == 'synthetic' or
  411. any(s.evidence.method == 'synthetic' for s in series if s.fan_id in ids)}
  412. def analyze_fan_switch_event(dataset):
  413. data, policy = dataset.data, dataset.policy
  414. events, results, warnings, used = _events(dataset), [], [], []
  415. series = [s for s in data.operating_series if same_mine(s, data) and s.mapping_confirmed and
  416. s.metric in {"flow", "static_pressure", "pressure"} and s.unit in UNITS.get(s.metric, {})]
  417. width, margin = policy.switch.window_minutes*60, policy.switch.transition_minutes*60
  418. latest = local_time(data.evaluation_time or datetime.now(LOCAL_ZONE)).timestamp()
  419. for event in events:
  420. t = local_time(event.switched_at).timestamp()
  421. timing = _switch_timing(event, dataset)
  422. if timing['interval_minutes'] is not None:
  423. endpoints = [local_time(event.from_stopped_at).timestamp(),local_time(event.to_started_at).timestamp()]
  424. begin, complete = min(endpoints),max(endpoints)
  425. else:
  426. begin = complete = t
  427. before, after = {}, {}
  428. inputs = {}
  429. for metric in ("flow", "static_pressure", "pressure"):
  430. chosen = [[s for s in series if s.fan_id == fan_id and s.metric == metric] for fan_id in (event.from_fan_id, event.to_fan_id)]
  431. if not all(len(items) == 1 for items in chosen):
  432. continue
  433. a, b = [items[0] for items in chosen]
  434. wa = _window(a, begin-margin-width, begin-margin, policy.switch, latest)
  435. wb = _window(b, complete+margin, complete+margin+width, policy.switch, latest)
  436. if wa and wb:
  437. before[metric], after[metric], inputs[metric] = wa, wb, (a, b)
  438. # 有调角/调频/故障或另一切换的窗口不做等价比较。
  439. interfering = [r for r in data.fan_state_records if same_mine(r, data) and
  440. r.fan_id in {event.from_fan_id, event.to_fan_id} and r.event_type in {"frequency_change", "angle_change", "fault"} and
  441. begin-margin-width <= local_time(r.observed_at).timestamp() < complete+margin+width]
  442. nearby_switches = [other for other in events if other is not event and
  443. begin-margin-width <= local_time(other.switched_at).timestamp() < complete+margin+width]
  444. if not before or interfering or nearby_switches:
  445. warnings.append(f"切换{event.event_id}未形成可比较的稳定窗口。")
  446. before, after, inputs = {}, {}, {}
  447. metrics, changes = {}, {}
  448. recovery = None
  449. exceeded = []
  450. for metric, left in before.items():
  451. right = after[metric]
  452. if left['mean'] <= 0:
  453. continue
  454. deviation = _relative(left["mean"], right["mean"])
  455. if deviation is None:
  456. continue
  457. changes[metric] = {"before": left["mean"], "after": right["mean"],
  458. "absolute_change": right["mean"]-left["mean"], "relative_change_percent": (right["mean"]-left["mean"])/abs(left["mean"])*100 if left["mean"] else 0}
  459. threshold = policy.switch.flow_deviation_percent if metric == "flow" else policy.switch.pressure_deviation_percent
  460. if _over_limit(deviation, threshold):
  461. exceeded.append(metric)
  462. name = "flow" if metric == "flow" else metric
  463. metrics[name+"_deviation_percent"] = deviation
  464. if not changes and timing['interval_minutes'] is None:
  465. continue
  466. # 从过渡期后开始寻找连续完整且回到基线带内的窗口,不能以一个点宣称恢复。
  467. recovery_width = policy.switch.minimum_duration_minutes*60
  468. for offset in range(margin, (policy.switch.maximum_recovery_minutes+policy.switch.window_minutes)*60+1, policy.switch.sample_interval_seconds) if changes else ():
  469. valid = True
  470. for metric, (a, b) in inputs.items():
  471. window = _window(b, complete+offset, complete+offset+recovery_width, policy.switch, latest)
  472. threshold = policy.switch.flow_deviation_percent if metric == "flow" else policy.switch.pressure_deviation_percent
  473. deviation = _relative(before[metric]["mean"], window["mean"]) if window else None
  474. if deviation is None or deviation > threshold:
  475. valid = False
  476. break
  477. if valid:
  478. recovery = offset/60
  479. break
  480. metrics["recovery_time_minutes"] = recovery
  481. metrics['switch_interval_minutes'] = timing['interval_minutes']
  482. for a, b in inputs.values():
  483. used.extend([a.evidence, b.evidence])
  484. used.append(event.evidence)
  485. state_evidence = [r.evidence for r in data.fan_state_records if same_mine(r,data) and
  486. r.fan_id in {event.from_fan_id,event.to_fan_id} and r.complete_fan_state and
  487. begin-policy.switch.sample_interval_seconds <= local_time(r.observed_at).timestamp() <= complete]
  488. used.extend(state_evidence)
  489. flow_status = 'fail' if 'flow' in exceeded else 'pass' if 'flow' in changes else 'insufficient'
  490. checks = {'switch_interval':timing['status'],'flow_change':flow_status}
  491. status = 'fail' if exceeded or timing['status'] == 'fail' else 'pass' if all(v == 'pass' for v in checks.values()) else 'insufficient'
  492. results.append({"event_id": event.event_id, "switched_at": datetime.fromtimestamp(complete,LOCAL_ZONE).isoformat(),
  493. "from_fan_id": event.from_fan_id, "to_fan_id": event.to_fan_id,
  494. "before_window": before, "after_window": after, "changes": changes, "metrics": metrics,
  495. 'timing':timing,'checks':checks,'limits':{'flow_deviation_percent':policy.switch.flow_deviation_percent},
  496. 'chart_data':_switch_chart_data(dataset,event,series,begin-margin-width,complete+margin+width),
  497. "status":status, "exceeded": exceeded + (['switch_interval'] if timing['status'] == 'fail' else []),
  498. "assessed_metrics": sorted(changes), "evidence_ids": sorted({evidence_id(e) for e in [event.evidence,*state_evidence, *(s.evidence for pair in inputs.values() for s in pair)]})})
  499. fully_assessed=bool(results) and len(results) == len(events) and all(r['status'] == 'pass' for r in results)
  500. return envelope({"status": "fail" if any(r["status"] == "fail" for r in results) else "pass" if fully_assessed else "insufficient",
  501. "events": results, "candidate_event_count": len(events),
  502. 'assessment_scope':['switch_interval','flow_change'],"warnings": warnings}, policy, used)
  503. def three_check_statuses(basic, switches):
  504. """只汇总基础参数、切换间隔、稳定风量三项;补充指标不参与否决。"""
  505. events = switches.get("events", [])
  506. def switch_check(name):
  507. statuses = [event.get("checks", {}).get(name, "insufficient") for event in events]
  508. if "fail" in statuses:
  509. return "fail"
  510. complete = len(events) >= switches.get("candidate_event_count", len(events))
  511. return "pass" if statuses and complete and all(status == "pass" for status in statuses) else "insufficient"
  512. return {"parameters": basic.get("status", "insufficient"),
  513. "switch_interval": switch_check("switch_interval"),
  514. "flow_change": switch_check("flow_change")}
  515. def _severe_findings(dataset, basic, switches):
  516. """仅针对已经形成明确不通过结果的核心项检查单独配置的严重线。"""
  517. policy = dataset.policy.severity
  518. findings = []
  519. for row in basic.get("rows", []):
  520. if (row["status"] == "mismatch" and row["field"] in policy.abnormal_parameter_fields
  521. and len(set(row.get("evidence_ids", []))) == 2):
  522. findings.append({"item": "parameters", "field": row["field"], "label": row["label"],
  523. "evidence_ids": row["evidence_ids"]})
  524. checks = (("switch_interval", "switch_interval_minutes", policy.abnormal_switch_interval_minutes),
  525. ("flow_change", "flow_deviation_percent", policy.abnormal_flow_deviation_percent))
  526. for event in switches.get("events", []):
  527. for item, metric, limit in checks:
  528. value = event.get("metrics", {}).get(metric)
  529. if (limit is not None and value is not None
  530. and (value >= limit or math.isclose(value, limit, rel_tol=0, abs_tol=1e-9))
  531. and event.get("checks", {}).get(item) == "fail" and event.get("evidence_ids")):
  532. findings.append({"item": item, "event_id": event["event_id"], "value": value,
  533. "severe_limit": limit, "evidence_ids": event["evidence_ids"]})
  534. return findings
  535. def match_fan_rules_and_knowledge(dataset, basic, curves, switches):
  536. return envelope({"status": "completed", "threshold_version": dataset.policy.threshold_version,
  537. "rules": three_check_statuses(basic, switches),
  538. "supplementary": {"performance_curves": curves["status"]},
  539. "thresholds": dataset.policy.model_dump(), "exception_assessment": None}, dataset.policy)
  540. def assess_fan_equivalent_capability(dataset, basic, curves, switches, rules):
  541. items = three_check_statuses(basic, switches)
  542. severe = _severe_findings(dataset, basic, switches)
  543. code = ("NORMAL" if all(status == "pass" for status in items.values()) else
  544. "ABNORMAL" if severe else "WARNING" if "fail" in items.values() else "UNDETERMINED")
  545. overall = "pass" if code == "NORMAL" else "fail" if code in {"WARNING", "ABNORMAL"} else "insufficient_evidence"
  546. ids = {e for result in (basic, curves, switches) for e in result["evidence_ids"]}
  547. return {"overall_status": overall, "result_code": code, "items": items,
  548. "required_items": ["parameters", "switch_interval", "flow_change"], "severe_findings": severe,
  549. "evidence_ids": sorted(ids), "rule_version": dataset.policy.rule_version,
  550. "threshold_version": dataset.policy.threshold_version, "confidence": None, "warnings": dataset.warnings,
  551. "reported_scope": [key for key, available in (("report_parameters", bool(basic["rows"])),
  552. ("report_performance", bool(curves["results"])), ("switch_performance", bool(switches["events"]))) if available]}
  553. def trace_fan_evidence_and_review(dataset, basic, curves, switches):
  554. index = {}
  555. for fan in dataset.fans:
  556. report = fan["report"]
  557. if report:
  558. for evidence in [report.evidence, *report.metadata_evidence.values(), *(r.evidence for r in [*report.points, *report.curves])]:
  559. if evidence.source or evidence.excerpt:
  560. index[evidence_id(evidence)] = evidence.model_dump(mode="json")
  561. for item in [*dataset.data.operating_series, *dataset.data.switch_records, *dataset.data.fan_state_records, *dataset.data.devices]:
  562. if same_mine(item, dataset.data):
  563. index[evidence_id(item.evidence)] = item.evidence.model_dump(mode="json")
  564. chains = [{"item": key, "evidence_ids": result["evidence_ids"], "rule_version": dataset.policy.rule_version}
  565. for key, result in (("basic_parameters", basic), ("performance_data", curves), ("switch_performance", switches))]
  566. return {"evidence_index": index, "evidence_chains": chains, "tool_version": TOOL_VERSION, "skill_version": SKILL_VERSION,
  567. "rule_version": dataset.policy.rule_version, "threshold_version": dataset.policy.threshold_version,
  568. "confidence": None, "warnings": dataset.warnings}