| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698 |
- """Cylinder-temperature feature extraction, grading and event segmentation.
- The scan is deliberately rule based: it reuses the pressure/vibration skeleton
- (valid running hours -> robust baseline -> relative deviation -> confirmed
- levels -> merged events) but adds the signals that matter for cylinder body
- temperature:
- * each cylinder's own recent baseline (level + robust z);
- * same-stage spread, compared with its own recent normal spread;
- * each cylinder's deviation from the stage median, compared with its own
- historic offset (removes the chronic "this cylinder always runs hotter"
- bias);
- * engineering high / high-high limits from ``site_point``.
- Thresholds are configurable so they can be calibrated against labelled faults.
- """
- from __future__ import annotations
- from collections import defaultdict, deque
- from datetime import datetime, timedelta
- from statistics import median
- from .config import (
- TEMP_BASELINE_HOURS,
- TEMP_CONFIRM_HOURS,
- TEMP_DROPOUT_REF,
- TEMP_MERGE_GAP_HOURS,
- TEMP_MIN_HISTORY_HOURS,
- TEMP_MIN_STEADY,
- TEMP_RESTART_GAP_HOURS,
- TEMP_SENSOR_MAX,
- TEMP_SENSOR_MIN,
- UnitConfig,
- )
- def linear_slope(values):
- pairs = [(index, value) for index, value in enumerate(values) if value is not None]
- if len(pairs) < 3:
- return None
- xbar = sum(x for x, _ in pairs) / len(pairs)
- ybar = sum(y for _, y in pairs) / len(pairs)
- denominator = sum((x - xbar) ** 2 for x, _ in pairs)
- if denominator == 0:
- return None
- return sum((x - xbar) * (y - ybar) for x, y in pairs) / denominator
- def unit_sort_key(value):
- import re
- match = re.search(r"[4-9]", str(value or ""))
- return int(match.group()) if match else 999
- def output_sort_key(row: dict):
- time_field = next(
- (field for field in ("小时", "开始小时", "周期开始小时", "开始日期", "日期") if row.get(field)),
- None,
- )
- time_value = str(row.get(time_field, "")) if time_field else ""
- return (unit_sort_key(row.get("机组")), time_value, int(row.get("批次") or 0), str(row.get("周期编号", "")))
- def clean_rows(rows):
- cleaned = []
- for row in rows:
- item = {}
- for key, value in row.items():
- if key.startswith("_"):
- continue
- if isinstance(value, float):
- item[key] = round(value, 6)
- else:
- item[key] = value
- cleaned.append(item)
- return cleaned
- def write_csv(path, rows, fields, headers):
- import csv
- from pathlib import Path
- path = Path(path)
- path.parent.mkdir(parents=True, exist_ok=True)
- rows = sorted(rows, key=output_sort_key)
- with path.open("w", encoding="utf-8-sig", newline="") as handle:
- writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore")
- writer.writerow({field: headers.get(field, field) for field in fields})
- writer.writerows(rows)
- LEVELS = ("轻微", "异常", "严重")
- def _fmt(value, digits=2):
- return "" if value is None else round(float(value), digits)
- def _median(values):
- values = [value for value in values if value is not None]
- return median(values) if values else None
- def _mad(values, center):
- return median(abs(value - center) for value in values) if values else 0.0
- def _baseline(values) -> tuple[float | None, float | None]:
- values = [value for value in values if value is not None]
- if len(values) < TEMP_MIN_HISTORY_HOURS:
- return None, None
- center = median(values)
- # Temperature sensors can be very stable; floor the scale at 0.5 degC and
- # 0.5% of the level so that z stays meaningful without exploding.
- scale = max(1.4826 * _mad(values, center), abs(center) * 0.005, 0.5)
- return center, scale
- def _longest_constant_run(values) -> int:
- longest = current = 1
- for previous, value in zip(values, values[1:]):
- if round(previous, 3) == round(value, 3):
- current += 1
- longest = max(longest, current)
- else:
- current = 1
- return longest if values else 0
- def channel_qc(rows: list[dict], config: UnitConfig) -> tuple[set[str], list[dict]]:
- """Flag frozen / dead / out-of-range cylinder channels."""
- bad: set[str] = set()
- report: list[dict] = []
- for column in config.columns:
- values = [row.get(f"{column}__avg") for row in rows if row.get(f"{column}__avg") is not None]
- label = config.labels.get(column, column)
- if not values:
- bad.add(column)
- report.append({"机组": config.name, "点位": column, "点位描述": label,
- "运行小时数": 0, "判定": "无数据"})
- continue
- count = len(values)
- zero_ratio = sum(1 for value in values if value == 0) / count
- out_ratio = sum(1 for value in values if value < TEMP_SENSOR_MIN or value > TEMP_SENSOR_MAX) / count
- distinct = len({round(value, 3) for value in values})
- constant_run = _longest_constant_run(values)
- reasons = []
- if zero_ratio > 0.20:
- reasons.append(f"零值比例{zero_ratio:.1%}")
- if out_ratio > 0.05:
- reasons.append(f"越界比例{out_ratio:.1%}")
- if distinct <= 3 and count > 50:
- reasons.append(f"仅{distinct}个不同值")
- if constant_run > 168:
- reasons.append(f"连续{constant_run}小时不变")
- if reasons:
- bad.add(column)
- report.append({
- "机组": config.name,
- "点位": column,
- "点位描述": label,
- "运行小时数": count,
- "最小值": _fmt(min(values)),
- "最大值": _fmt(max(values)),
- "中位数": _fmt(median(values)),
- "不同值个数": distinct,
- "最长不变小时": constant_run,
- "零值比例": round(zero_ratio, 4),
- "越界比例": round(out_ratio, 4),
- "判定": "坏道" if reasons else "正常",
- "原因": ";".join(reasons),
- })
- return bad, report
- def _assign_cycles(rows: list[dict], config: UnitConfig) -> list[dict]:
- """Attach a per-batch run-cycle id; gaps > 12h start a new cycle."""
- ordered = sorted(rows, key=lambda row: (row["import_batch_id"], row["hour_start"]))
- cycle = 0
- previous_batch = None
- previous_dt = None
- for row in ordered:
- batch = row["import_batch_id"]
- dt = row["hour_start"]
- if (
- previous_batch is None
- or batch != previous_batch
- or previous_dt is None
- or dt - previous_dt > timedelta(hours=TEMP_RESTART_GAP_HOURS)
- ):
- cycle += 1
- row["_cycle_id"] = f"{config.unit}-T{cycle:03d}"
- previous_batch, previous_dt = batch, dt
- return ordered
- def _snapshot(row: dict, columns: list[str]) -> tuple[dict, float, float | None, dict] | None:
- raw = {}
- for column in columns:
- if row.get(f"{column}__dropout"):
- continue
- value = row.get(f"{column}__avg")
- if value is None:
- continue
- if value <= 0 or value < TEMP_SENSOR_MIN or value > TEMP_SENSOR_MAX:
- continue
- raw[column] = value
- if not raw:
- return None
- if max(raw.values()) < TEMP_MIN_STEADY:
- return None
- # When the stage is clearly hot, a channel reading ambient is a stopped or
- # dead sensor, not a real cylinder; drop it before stage statistics.
- if max(raw.values()) >= TEMP_DROPOUT_REF:
- values = {column: value for column, value in raw.items() if value >= TEMP_MIN_STEADY}
- else:
- values = raw
- if not values:
- return None
- stage_median = median(values.values())
- spread = max(values.values()) - min(values.values()) if len(values) >= 2 else None
- peer = {column: value - stage_median for column, value in values.items()}
- return values, stage_median, spread, peer
- def _mark_dropouts(rows: list[dict], config: UnitConfig) -> None:
- """Mark abrupt downward channel glitches (a channel dropping alone).
- A real load cut moves all cylinders together and the whole stage falls
- below the steady floor, so it is filtered elsewhere. A single channel that
- suddenly reads far below its own neighbours (and below 60 degC) is a sensor
- / acquisition dropout and must not inflate the same-stage spread.
- """
- for column in config.columns:
- series = []
- for index, row in enumerate(rows):
- value = row.get(f"{column}__avg")
- if value is None or value <= 0 or value < TEMP_SENSOR_MIN or value > TEMP_SENSOR_MAX:
- continue
- series.append((index, row["hour_start"], value))
- for position, (index, moment, value) in enumerate(series):
- previous = [item[2] for item in series[max(0, position - 6):position]
- if moment - item[1] <= timedelta(hours=24)]
- following = [item[2] for item in series[position + 1:position + 7]
- if item[1] - moment <= timedelta(hours=24)]
- reference = previous + following
- if len(reference) < 3:
- continue
- center = median(reference)
- # A transient drop of >15 degC that recovers within the local
- # window is an acquisition dropout; a persistent drop keeps the
- # reference low as well and therefore survives.
- if value < center - 15:
- rows[index][f"{column}__dropout"] = True
- def _global_offsets(rows: list[dict], config: UnitConfig) -> tuple[dict, dict]:
- spreads: dict[str, list] = defaultdict(list)
- peers: dict[str, list] = defaultdict(list)
- for row in rows:
- for stage, columns in config.stages.items():
- snapshot = _snapshot(row, columns)
- if not snapshot:
- continue
- _, _, spread, peer = snapshot
- if spread is not None:
- spreads[stage].append(spread)
- for column, value in peer.items():
- peers[column].append(value)
- return (
- {stage: _median(values) for stage, values in spreads.items()},
- {column: _median(values) for column, values in peers.items()},
- )
- def _grade(values: dict, metrics: dict, config: UnitConfig, thresholds: dict) -> tuple[str, list[str]]:
- """Grade one stage-hour from same-stage relative signals only.
- Engineering PVHigh/PVHighHigh are deliberately *not* used as triggers: they
- are the plant's existing absolute alarms, and a hot summer day pushes every
- cylinder above them. The added value here is the same-stage difference.
- """
- reasons: list[str] = []
- severe = abnormal = mild = False
- spread_delta = metrics.get("spread_delta")
- if spread_delta is not None:
- if spread_delta >= thresholds["severe_spread"]:
- severe = True
- reasons.append(f"级差较平时+{spread_delta:.1f}℃")
- elif spread_delta >= thresholds["abnormal_spread"]:
- abnormal = True
- reasons.append(f"级差较平时+{spread_delta:.1f}℃")
- elif spread_delta >= thresholds["mild_spread"]:
- mild = True
- reasons.append(f"级差较平时+{spread_delta:.1f}℃")
- peer_high = metrics.get("max_peer_anom")
- if peer_high is not None:
- if peer_high >= thresholds["severe_peer"]:
- severe = True
- reasons.append(f"同级偏高较平时+{peer_high:.1f}℃")
- elif peer_high >= thresholds["abnormal_peer"]:
- abnormal = True
- reasons.append(f"同级偏高较平时+{peer_high:.1f}℃")
- elif peer_high >= thresholds["mild_peer"]:
- mild = True
- reasons.append(f"同级偏高较平时+{peer_high:.1f}℃")
- peer_low = metrics.get("max_peer_low")
- if peer_low is not None:
- if peer_low >= thresholds["severe_peer_low"]:
- severe = True
- reasons.append(f"同级偏低较平时-{peer_low:.1f}℃")
- elif peer_low >= thresholds["abnormal_peer_low"]:
- abnormal = True
- reasons.append(f"同级偏低较平时-{peer_low:.1f}℃")
- elif peer_low >= thresholds["mild_peer_low"]:
- mild = True
- reasons.append(f"同级偏低较平时-{peer_low:.1f}℃")
- # Own-baseline level/z is load-driven, so it is reported but not used as a
- # trigger; a single-cylinder stage (7#/8#/9# 三级) has no same-stage peer and
- # therefore stays quiet unless the same-stage logic above can fire.
- if severe:
- return "严重", reasons
- if abnormal:
- return "异常", reasons
- if mild:
- return "轻微", reasons
- return "正常", reasons
- def compute_features(
- rows: list[dict],
- config: UnitConfig,
- bad_channels: set[str],
- thresholds: dict | None = None,
- ) -> list[dict]:
- thresholds = {**config.thresholds, **(thresholds or {})}
- valid = [row for row in _assign_cycles(rows, config) if row.get("valid")]
- _mark_dropouts(valid, config)
- global_spread, global_peer = _global_offsets(valid, config)
- history: dict[str, deque] = {column: deque() for column in config.columns}
- history_peer: dict[str, deque] = {column: deque() for column in config.columns}
- history_spread: dict[str, deque] = {stage: deque() for stage in config.stages}
- rise_count: dict[str, int] = {column: 0 for column in config.columns}
- last_dt: dict[str, datetime | None] = {column: None for column in config.columns}
- last_stage_dt: dict[str, datetime | None] = {stage: None for stage in config.stages}
- features: list[dict] = []
- for row in valid:
- dt: datetime = row["hour_start"]
- window_start = dt - timedelta(hours=TEMP_BASELINE_HOURS)
- for column, values in history.items():
- while values and values[0][0] < window_start:
- values.popleft()
- for column, values in history_peer.items():
- while values and values[0][0] < window_start:
- values.popleft()
- for values in history_spread.values():
- while values and values[0][0] < window_start:
- values.popleft()
- for stage, columns in config.stages.items():
- usable = [column for column in columns if column not in bad_channels]
- snapshot = _snapshot(row, usable)
- if not snapshot:
- continue
- values, stage_median, spread, peer = snapshot
- spread_values = [value for _, value in history_spread[stage]]
- spread_base = _median(spread_values) if len(spread_values) >= TEMP_MIN_HISTORY_HOURS else global_spread.get(stage)
- spread_delta = None
- if spread is not None and spread_base is not None:
- spread_delta = spread - spread_base
- per_column: dict[str, dict] = {}
- for column, value in values.items():
- own_values = [item for _, item in history[column]]
- # Daily load cycles move every cylinder together. A time-of-day
- # baseline removes that common ramp, which is essential for a
- # single-cylinder stage (7#/8#/9# 三级) that has no same-stage peer.
- tod_values = [item for moment, item in history[column] if moment.hour == dt.hour]
- if len(tod_values) >= 3:
- base = median(tod_values)
- scale = max(1.4826 * _mad(tod_values, base), abs(base) * 0.005, 0.5)
- else:
- base, scale = _baseline(own_values)
- delta = value - base if base is not None else None
- z = delta / scale if (delta is not None and scale) else None
- peer_values = [item for _, item in history_peer[column]]
- peer_base = _median(peer_values) if len(peer_values) >= TEMP_MIN_HISTORY_HOURS else global_peer.get(column, 0.0)
- peer_dev = peer[column]
- peer_anom = peer_dev - peer_base if peer_base is not None else None
- series = own_values[-5:] + [value]
- slope = linear_slope(series) if len(series) >= 3 else None
- if delta is not None and delta > 1.0:
- rise_count[column] = rise_count[column] + 1 if last_dt[column] and dt - last_dt[column] == timedelta(hours=1) else 1
- else:
- rise_count[column] = 0
- per_column[column] = {
- "value": value, "base": base, "delta": delta, "z": z,
- "peer_dev": peer_dev, "peer_anom": peer_anom,
- "slope": slope, "rise": rise_count[column],
- }
- history[column].append((dt, value))
- history_peer[column].append((dt, peer_dev))
- last_dt[column] = dt
- if spread is not None:
- history_spread[stage].append((dt, spread))
- deltas = [item["delta"] for item in per_column.values() if item["delta"] is not None]
- zs = [item["z"] for item in per_column.values() if item["z"] is not None]
- peer_anoms = [item["peer_anom"] for item in per_column.values() if item["peer_anom"] is not None]
- insufficient = not deltas
- max_delta = max(deltas) if deltas else None
- max_z = max(zs) if zs else None
- max_peer_high = max(peer_anoms) if peer_anoms else None
- max_peer_low = abs(min(peer_anoms)) if peer_anoms else None
- strongest = max(
- (column for column in per_column if per_column[column]["delta"] is not None),
- key=lambda column: per_column[column]["delta"],
- default=None,
- )
- peak_peer_column = max(
- (column for column in per_column if per_column[column]["peer_anom"] is not None),
- key=lambda column: per_column[column]["peer_anom"],
- default=None,
- )
- low_peer_column = min(
- (column for column in per_column if per_column[column]["peer_anom"] is not None),
- key=lambda column: per_column[column]["peer_anom"],
- default=None,
- )
- slope = per_column[strongest]["slope"] if strongest else None
- rise = per_column[strongest]["rise"] if strongest else None
- high_values = [config.limits.get(column, {}).get("high") for column in values]
- high_values = [value for value in high_values if value is not None]
- high_high_values = [config.limits.get(column, {}).get("high_high") for column in values]
- high_high_values = [value for value in high_high_values if value is not None]
- metrics = {
- "spread": spread, "spread_base": spread_base, "spread_delta": spread_delta,
- "max_delta": max_delta, "max_z": max_z, "max_peer_anom": max_peer_high,
- "slope": slope,
- }
- if insufficient:
- raw = "数据不足"
- reasons = ["有效基线不足24小时"]
- else:
- raw, reasons = _grade(values, metrics, config, thresholds)
- features.append({
- "机组": config.name,
- "批次": row["import_batch_id"],
- "周期编号": row["_cycle_id"],
- "小时": dt.strftime("%Y-%m-%d %H:%M:%S"),
- "级": stage,
- "有效缸数": len(values),
- "运行覆盖率": round(row.get("coverage") or 0.0, 4),
- "最高温度": _fmt(max(values.values())),
- "最低温度": _fmt(min(values.values())),
- "最高温度缸": config.labels.get(max(values, key=values.get), ""),
- "级差": _fmt(spread),
- "级差基线": _fmt(spread_base),
- "级差变化": _fmt(spread_delta),
- "最大自身偏离": _fmt(max_delta),
- "最大自身偏离缸": config.labels.get(strongest, "") if strongest else "",
- "最大同级偏离": _fmt(max_peer_high),
- "最大同级偏离缸": config.labels.get(peak_peer_column, "") if peak_peer_column else "",
- "最大同级偏低": _fmt(max_peer_low),
- "最大同级偏低缸": config.labels.get(low_peer_column, "") if low_peer_column else "",
- "最大稳健z": _fmt(max_z),
- "最强缸": config.labels.get(strongest, "") if strongest else "",
- "最高报警阈值": _fmt(min(high_values)) if high_values else "",
- "高高报警阈值": _fmt(min(high_high_values)) if high_high_values else "",
- "超告警缸": "、".join(
- config.labels.get(column, column) for column in values
- if (config.limits.get(column, {}).get("high") is not None
- and values[column] >= config.limits[column]["high"])
- ),
- "超高高缸": "、".join(
- config.labels.get(column, column) for column in values
- if (config.limits.get(column, {}).get("high_high") is not None
- and values[column] >= config.limits[column]["high_high"])
- ),
- "近6小时斜率": _fmt(slope, 4),
- "连续上升小时数": rise or 0,
- "各缸温度": ";".join(f"{config.labels.get(column, column)}={value:.2f}" for column, value in values.items()),
- "各缸自身偏离": ";".join(f"{config.labels.get(column, column)}={per_column[column]['delta']:+.2f}"
- for column in values if per_column[column]["delta"] is not None),
- "各缸同级偏离": ";".join(f"{config.labels.get(column, column)}={per_column[column]['peer_dev']:+.2f}"
- for column in values),
- "原始等级": raw,
- "判定依据": ";".join(reasons),
- "_dt": dt,
- "_strongest": strongest,
- })
- _confirm_levels(features, thresholds)
- return features
- def _confirm_levels(features: list[dict], thresholds: dict) -> None:
- confirm_hours = int(thresholds.get("confirm_hours", TEMP_CONFIRM_HOURS))
- grouped: dict[tuple, list[dict]] = defaultdict(list)
- for row in features:
- grouped[(row["机组"], row["批次"], row["级"])].append(row)
- order = {"轻微": 1, "异常": 2, "严重": 3}
- for rows in grouped.values():
- rows.sort(key=lambda row: row["_dt"])
- index = 0
- while index < len(rows):
- raw = rows[index]["原始等级"]
- if raw in ("正常", "数据不足"):
- rows[index]["温度等级"] = raw
- index += 1
- continue
- end = index
- while end + 1 < len(rows):
- candidate = rows[end + 1]["原始等级"]
- if candidate in ("正常", "数据不足"):
- break
- if rows[end + 1]["_dt"] - rows[end]["_dt"] != timedelta(hours=1):
- break
- end += 1
- run = rows[index:end + 1]
- run_length = len(run)
- highest = max((row["原始等级"] for row in run), key=lambda value: order[value])
- if run_length >= confirm_hours:
- final = highest
- elif highest == "严重":
- final = "严重" if any(row["超高高缸"] for row in run) else "异常"
- else:
- final = "轻微"
- for row in run:
- row["温度等级"] = final
- index = end + 1
- def build_segments(features: list[dict], config: UnitConfig, min_hours: int = 2) -> list[dict]:
- grouped: dict[tuple, list[dict]] = defaultdict(list)
- for row in features:
- if row["温度等级"] in LEVELS:
- grouped[(row["机组"], row["批次"], row["级"], row["温度等级"])].append(row)
- segments: list[dict] = []
- for (unit, batch, stage, level), rows in grouped.items():
- rows.sort(key=lambda row: row["_dt"])
- current: list[dict] = []
- for row in rows:
- if not current or row["_dt"] - current[-1]["_dt"] <= timedelta(hours=TEMP_MERGE_GAP_HOURS):
- current.append(row)
- else:
- segments.append(_build_segment(current, unit, batch, stage, level, config))
- current = [row]
- if current:
- segments.append(_build_segment(current, unit, batch, stage, level, config))
- segments = [segment for segment in segments if segment["有效运行小时数"] >= min_hours]
- return sorted(segments, key=output_sort_key)
- def _build_segment(rows: list[dict], unit: str, batch: int, stage: str, level: str, config: UnitConfig) -> dict:
- first, last = rows[0], rows[-1]
- strongest = [row for row in rows if row["最强缸"]]
- peak_row = max(rows, key=lambda row: row["最大自身偏离"] or -1e9)
- spread_rows = [row for row in rows if row["级差"] not in ("", None)]
- peak_spread_row = max(spread_rows, key=lambda row: row["级差"]) if spread_rows else None
- peer_rows = [row for row in rows if row["最大同级偏离"] not in ("", None)]
- peak_peer_row = max(peer_rows, key=lambda row: row["最大同级偏离"]) if peer_rows else None
- z_rows = [row for row in rows if row["最大稳健z"] not in ("", None)]
- peak_z_row = max(z_rows, key=lambda row: row["最大稳健z"]) if z_rows else None
- return {
- "机组": unit,
- "批次": batch,
- "周期编号": first["周期编号"],
- "级": stage,
- "温度阶段": level,
- "开始小时": first["小时"],
- "结束小时": last["小时"],
- "持续自然小时数": int((last["_dt"] - first["_dt"]).total_seconds() / 3600) + 1,
- "有效运行小时数": len(rows),
- "峰值时刻": peak_row["小时"],
- "峰值缸": peak_row["最强缸"],
- "最高温度": max(row["最高温度"] for row in rows if row["最高温度"] not in ("", None)),
- "最大自身偏离": peak_row["最大自身偏离"],
- "最大级差": peak_spread_row["级差"] if peak_spread_row else "",
- "最大级差基线": peak_spread_row["级差基线"] if peak_spread_row else "",
- "最大级差变化": peak_spread_row["级差变化"] if peak_spread_row else "",
- "最大同级偏离": peak_peer_row["最大同级偏离"] if peak_peer_row else "",
- "最大稳健z": peak_z_row["最大稳健z"] if peak_z_row else "",
- "阶段最高报警阈值": min((row["最高报警阈值"] for row in rows if row["最高报警阈值"] not in ("", None)), default=""),
- "阶段高高报警阈值": min((row["高高报警阈值"] for row in rows if row["高高报警阈值"] not in ("", None)), default=""),
- "阶段超告警缸": "、".join(sorted({item for row in rows for item in str(row["超告警缸"]).split("、") if item})),
- "阶段超高高缸": "、".join(sorted({item for row in rows for item in str(row["超高高缸"]).split("、") if item})),
- "最强缸": max(set(row["最强缸"] for row in strongest), key=lambda value: sum(1 for row in strongest if row["最强缸"] == value)) if strongest else "",
- "阶段触发依据": ";".join(row["判定依据"] for row in rows[:3] if row["判定依据"]),
- "_dt": first["_dt"],
- }
- def summarize_cycles(features: list[dict], segments: list[dict], config: UnitConfig) -> list[dict]:
- by_cycle: dict[tuple, list[dict]] = defaultdict(list)
- for row in features:
- by_cycle[(row["机组"], row["批次"], row["周期编号"], row["级"])].append(row)
- result: list[dict] = []
- for (unit, batch, cycle_id, stage), rows in by_cycle.items():
- rows.sort(key=lambda row: row["_dt"])
- stage_segments = [segment for segment in segments
- if segment["周期编号"] == cycle_id and segment["级"] == stage]
- summary = {
- "机组": unit,
- "批次": batch,
- "周期编号": cycle_id,
- "级": stage,
- "周期开始小时": rows[0]["小时"],
- "周期结束小时": rows[-1]["小时"],
- "有效运行小时数": len(rows),
- "阶段演化顺序": "→".join(segment["温度阶段"] for segment in stage_segments) or "正常",
- }
- for level in LEVELS:
- selected = [segment for segment in stage_segments if segment["温度阶段"] == level]
- summary[f"{level}阶段开始小时"] = selected[0]["开始小时"] if selected else ""
- summary[f"{level}阶段结束小时"] = selected[-1]["结束小时"] if selected else ""
- summary[f"{level}阶段运行小时数"] = sum(segment["有效运行小时数"] for segment in selected)
- summary["周期最高温度"] = max((row["最高温度"] for row in rows if row["最高温度"] not in ("", None)), default="")
- summary["周期最大级差"] = max((row["级差"] for row in rows if row["级差"] not in ("", None)), default="")
- summary["周期最大级差变化"] = max((row["级差变化"] for row in rows if row["级差变化"] not in ("", None)), default="")
- summary["周期最大自身偏离"] = max((row["最大自身偏离"] for row in rows if row["最大自身偏离"] not in ("", None)), default="")
- result.append(summary)
- return result
- def run(rows: list[dict], config: UnitConfig, thresholds: dict | None = None, min_hours: int = 2):
- valid = [row for row in rows if row.get("valid")]
- bad_channels, qc_report = channel_qc(valid, config)
- features = compute_features(rows, config, bad_channels, thresholds)
- segments = build_segments(features, config, min_hours)
- cycles = summarize_cycles(features, segments, config)
- return bad_channels, qc_report, features, segments, cycles
- FEATURE_FIELDS = [
- "机组", "批次", "周期编号", "小时", "级", "有效缸数", "运行覆盖率",
- "最高温度", "最低温度", "最高温度缸", "最高报警阈值", "高高报警阈值", "超告警缸", "超高高缸",
- "级差", "级差基线", "级差变化",
- "最大自身偏离", "最大自身偏离缸", "最大同级偏离", "最大同级偏离缸", "最大同级偏低", "最大同级偏低缸", "最大稳健z", "最强缸",
- "近6小时斜率", "连续上升小时数",
- "各缸温度", "各缸自身偏离", "各缸同级偏离",
- "温度等级", "原始等级", "判定依据",
- ]
- SEGMENT_FIELDS = [
- "机组", "批次", "周期编号", "级", "温度阶段", "开始小时", "结束小时",
- "持续自然小时数", "有效运行小时数", "峰值时刻", "峰值缸", "最高温度",
- "最大自身偏离", "最大级差", "最大级差基线", "最大级差变化", "最大同级偏离", "最大稳健z",
- "阶段最高报警阈值", "阶段高高报警阈值", "阶段超告警缸", "阶段超高高缸", "最强缸", "阶段触发依据",
- ]
- CYCLE_FIELDS = [
- "机组", "批次", "周期编号", "级", "周期开始小时", "周期结束小时", "有效运行小时数", "阶段演化顺序",
- "轻微阶段开始小时", "轻微阶段结束小时", "轻微阶段运行小时数",
- "异常阶段开始小时", "异常阶段结束小时", "异常阶段运行小时数",
- "严重阶段开始小时", "严重阶段结束小时", "严重阶段运行小时数",
- "周期最高温度", "周期最大级差", "周期最大级差变化", "周期最大自身偏离",
- ]
- QC_FIELDS = [
- "机组", "点位", "点位描述", "运行小时数", "最小值", "最大值", "中位数",
- "不同值个数", "最长不变小时", "零值比例", "越界比例", "判定", "原因",
- ]
- def write_results(output_dir, qc_report, features, segments, cycles):
- output_dir.mkdir(parents=True, exist_ok=True)
- write_csv(output_dir / "温度小时特征.csv", clean_rows(features), FEATURE_FIELDS, {field: field for field in FEATURE_FIELDS})
- write_csv(output_dir / "温度异常段.csv", clean_rows(segments), SEGMENT_FIELDS, {field: field for field in SEGMENT_FIELDS})
- write_csv(output_dir / "温度演化周期.csv", clean_rows(cycles), CYCLE_FIELDS, {field: field for field in CYCLE_FIELDS})
- write_csv(output_dir / "温度通道质控.csv", clean_rows(qc_report), QC_FIELDS, {field: field for field in QC_FIELDS})
- __all__ = [
- "LEVELS", "channel_qc", "compute_features", "build_segments", "summarize_cycles", "run",
- "write_results", "FEATURE_FIELDS", "SEGMENT_FIELDS", "CYCLE_FIELDS", "QC_FIELDS",
- ]
|