"""Анализ надёжности оборудования по данным портала. Методика — «Надёжность и безопасность» (ИАТЭ, 2005) и ГОСТ 27.002, кратко: - отказы считаются пуассоновским потоком (ординарный, без последействия), на участке нормальной эксплуатации λ ≈ const; - λ = n / T, наработка на отказ θ = T / n, где T — наработка за период (моточасы по счётчику), n — число отказов; - консервативная оценка по мало- или безотказному опыту (раздел 3.1): верхняя граница λ при доверии Pα — χ²(Pα; 2n+2) / (2T); при n = 0 это −ln(1−Pα)/T (2,30/T при 0,9), «средневероятная» — при Pα = 0,5; - среднее время восстановления Tв и коэффициент готовности Kг = θ / (θ + Tв); - прогноз числа отказов на следующий период по Пуассону: μ = λ·t, P(≥1) = 1 − e^(−μ); - модель «параметр — граница работоспособности» (раздел 3.2): линейный дрейф числового пункта чек-листа к границе нормы и частота выходов за неё (ν). Абсолютные значения приблизительны (мало данных, допущения в сторону запаса); главная польза — сравнение машин между собой. """ import csv import io import math from collections import Counter, defaultdict from datetime import datetime, timedelta from sqlalchemy import select from sqlalchemy.orm import Session from . import config from .models import Department, Equipment, Incident, IncidentUnit, Location, MeterReading, Order, OrderItem, ServiceEvent, ServiceUnit CAUSE_RU = {"wear": "Износ и старение", "operation": "Нарушение условий эксплуатации, ошибка персонала", "external": "Внешние воздействия (температура, влага, пыль, вибрация)", "power": "Электропитание или смежные системы", "defect": "Дефект изготовления, монтажа или прошлого ремонта", "secondary": "Вследствие отказа другого узла", "unknown": "Не установлена"} ACTION_RU = {"repair": "Ремонт", "replace": "Замена узла", "adjust": "Регулировка или наладка", "restart": "Перезапуск без ремонта (сбой)"} NATURE_RU = {"sudden": "Внезапный", "gradual": "Постепенный"} PRECURSOR_RU = {"inspection": "Замечено на обходе или в чек-листе", "fault_report": "Было сообщение о неисправности", "none": "Признаков не было"} SERVICE_KIND_RU = {"initial": "начальное состояние", "order": "плановое по наряду", "reset": "вне наряда", "incident": "замена при отказе"} # ---------- статистика ---------- def poisson_tail(k: int, x: float) -> float: """P(χ²(2k) > x) = Σ_{i float: """Квантиль χ² с 2k степенями свободы уровня p (P(χ² ≤ x) = p), бисекцией.""" lo, hi = 0.0, max(10.0, 4.0 * k + 40) for _ in range(200): mid = (lo + hi) / 2 if 1 - poisson_tail(k, mid) < p: lo = mid else: hi = mid return (lo + hi) / 2 def lambda_bound(n: int, t: float, p: float) -> float | None: """Оценка интенсивности отказов при доверии p по n отказам за наработку t (раздел 3.1 методички).""" if t <= 0: return None return chi2_quantile_even(p, n + 1) / 2 / t # ---------- наработка ---------- def hours_between(readings: list[tuple[datetime, float]], start: datetime, end: datetime) -> float | None: """Наработка по счётчику за [start, end]: сумма приростов (коррекции счётчика вниз не уменьшают сумму), значения на границах — линейная интерполяция между показаниями. None — показаний недостаточно.""" if len(readings) < 2: return None pts = sorted(readings) def at(t: datetime) -> float: if t <= pts[0][0]: return pts[0][1] if t >= pts[-1][0]: return pts[-1][1] for (t1, h1), (t2, h2) in zip(pts, pts[1:]): if t1 <= t <= t2: if t2 == t1 or h2 < h1: return h1 return h1 + (h2 - h1) * (t - t1).total_seconds() / (t2 - t1).total_seconds() return pts[-1][1] inner = [(start, at(start))] + [(t, h) for t, h in pts if start < t < end] + [(end, at(end))] total = sum(max(0.0, b[1] - a[1]) for a, b in zip(inner, inner[1:])) covered = min(end, pts[-1][0]) > max(start, pts[0][0]) return total if covered else None def failure_at(i: Incident) -> datetime: return i.escalated_at or i.occurred_at def _r(v, n=1): return None if v is None else round(v, n) # ---------- сводный отчёт ---------- def analyze(db: Session, start: datetime, end: datetime, equipment_ids: set[int] | None, conf: float = 0.9, horizon_days: int = 30) -> dict: eqs = [e for e in db.scalars(select(Equipment).where(Equipment.is_active.is_(True)).order_by(Equipment.name)) if equipment_ids is None or e.id in equipment_ids] ids = [e.id for e in eqs] locs = dict(db.execute(select(Location.id, Location.name)).all()) deps = dict(db.execute(select(Department.id, Department.name)).all()) readings = defaultdict(list) for eid, at, h in db.execute(select(MeterReading.equipment_id, MeterReading.at, MeterReading.hours).where(MeterReading.equipment_id.in_(ids))): readings[eid].append((at, h)) incs = [i for i in db.scalars(select(Incident).where(Incident.equipment_id.in_(ids))).all()] units = {u.id: u for u in db.scalars(select(ServiceUnit).where(ServiceUnit.equipment_id.in_(ids)))} period_h = (end - start).total_seconds() / 3600 days = max(1.0, period_h / 24) rows, tot = [], Counter() causes, natures, actions, precursors, cause_units, months = Counter(), Counter(), Counter(), Counter(), Counter(), Counter() tv_all = [] for e in eqs: mine = [i for i in incs if i.equipment_id == e.id] fails = [i for i in mine if i.kind == "incident" and start <= failure_at(i) < end] faults = [i for i in mine if start <= i.occurred_at < end and (i.kind == "fault" or i.escalated_at)] escalated = [i for i in fails if i.escalated_at] oh = hours_between(readings.get(e.id, []), start, end) basis = "meter" if oh is None: # нет счётчика — календарное время (консервативно: машина работала круглосуточно) oh, basis = period_h, "calendar" n = len(fails) lam = n / oh if oh > 0 and n else None lam_up = lambda_bound(n, oh, conf) lam_mid = lambda_bound(n, oh, 0.5) theta = oh / n if n and oh > 0 else None tv = [(i.restored_at - failure_at(i)).total_seconds() / 3600 for i in fails if i.restored_at and i.restored_at >= failure_at(i)] repair = [(i.restored_at - i.repair_started_at).total_seconds() / 3600 for i in fails if i.restored_at and i.repair_started_at and i.restored_at >= i.repair_started_at] wait = [i.parts_wait_hours for i in fails if i.parts_wait_hours] tv_mean = sum(tv) / len(tv) if tv else None tv_all += tv down = sum(tv) open_fail = next((i for i in mine if i.kind == "incident" and i.status == "open"), None) if open_fail: # текущий простой учитываем до конца периода down += max(0.0, (min(end, config.now()) - max(start, failure_at(open_fail))).total_seconds() / 3600) kg = theta / (theta + tv_mean) if theta and tv_mean is not None else None # прогноз: ожидаемая наработка за горизонт по средней интенсивности использования rate = oh / days lam_fc = lam if lam else lam_mid mu = (lam_fc or 0) * rate * horizon_days rows.append({ "equipment_id": e.id, "equipment": e.name, "inventory_no": e.inventory_no, "location": locs.get(e.location_id, ""), "department": deps.get(e.department_id, ""), "is_crane": bool(e.is_crane), "basis": basis, "hours": _r(oh), "failures": n, "faults": len(faults), "escalated": len(escalated), "lambda": lam, "lambda_upper": lam_up, "theta": _r(theta), "theta_lower": _r(1 / lam_up if lam_up else None), "theta_median": _r(1 / lam_mid if lam_mid else None), "mttr": _r(tv_mean, 2), "repair_mean": _r(sum(repair) / len(repair), 2) if repair else None, "wait_mean": _r(sum(wait) / len(wait), 2) if wait else None, "downtime": _r(down, 2), "availability": _r(kg, 4), "availability_period": _r(max(0.0, 1 - down / period_h), 4) if period_h else None, "forecast_mu": _r(mu, 3), "forecast_p": _r(1 - math.exp(-mu), 3) if mu else 0.0, }) tot["hours"] += oh tot["failures"] += n tot["faults"] += len(faults) tot["escalated"] += len(escalated) tot["downtime"] += down for i in fails: causes[i.cause or "—"] += 1 natures[i.nature or "—"] += 1 actions[i.action or "—"] += 1 precursors[i.precursor or "—"] += 1 cu = units.get(i.cause_unit_id) cause_units[(e.name + " / " + cu.name) if cu else (e.name + " / " + (i.cause_unit_other or "узел не указан"))] += 1 months[failure_at(i).strftime("%Y-%m")] += 1 # по парку целиком n, t = tot["failures"], tot["hours"] lu = lambda_bound(n, t, conf) fleet = {"machines": len(rows), "hours": _r(t), "failures": n, "faults": tot["faults"], "escalated": tot["escalated"], "theta": _r(t / n) if n else None, "theta_lower": _r(1 / lu) if lu else None, "mttr": _r(sum(tv_all) / len(tv_all), 2) if tv_all else None, "downtime": _r(tot["downtime"], 2), "without_failures": sum(1 for r in rows if not r["failures"])} def dist(c: Counter, names: dict) -> list[dict]: total = sum(c.values()) or 1 return [{"key": k, "name": names.get(k, "не указано" if k == "—" else k), "n": v, "pct": round(100 * v / total)} for k, v in c.most_common()] rows.sort(key=lambda r: (-(r["failures"]), -(r["faults"]), r["theta_lower"] or 0)) return {"from": config.iso(start), "to": config.iso(end), "confidence": conf, "horizon_days": horizon_days, "fleet": fleet, "rows": rows, "causes": dist(causes, CAUSE_RU), "natures": dist(natures, NATURE_RU), "actions": dist(actions, ACTION_RU), "precursors": dist(precursors, PRECURSOR_RU), "cause_units": [{"name": k, "n": v} for k, v in cause_units.most_common(15)], "months": [{"month": k, "n": months[k]} for k in sorted(months)]} # ---------- «параметр — граница»: дрейф числовых пунктов чек-листов ---------- def drift(db: Session, equipment_ids: set[int] | None, days: int = 180, horizon: int = 90) -> list[dict]: """Числовые пункты нарядов с нормой: тренд значения к границе и частота выходов за неё (ν). Прогноз даты выхода за границу — линейная регрессия по времени (линейный дрейф параметра).""" since = config.now() - timedelta(days=days) q = (select(OrderItem, Order.equipment_id).join(Order, Order.id == OrderItem.order_id) .where(OrderItem.answer_type == "number", OrderItem.answered_at.is_not(None), OrderItem.answered_at >= since, Order.equipment_id.is_not(None))) groups = defaultdict(list) for it, eid in db.execute(q): if equipment_ids is not None and eid not in equipment_ids: continue if it.min_value is None and it.max_value is None: continue try: v = float(str(it.value).replace(",", ".")) except (TypeError, ValueError): continue groups[(eid, it.text.strip(), it.min_value, it.max_value, it.unit)].append((it.answered_at, v, bool(it.out_of_range))) names = dict(db.execute(select(Equipment.id, Equipment.name)).all()) now = config.now() out = [] for (eid, text, lo, hi, unit), pts in groups.items(): if len(pts) < 3: continue pts.sort() t0 = pts[0][0] xs = [(t - t0).total_seconds() / 86400 for t, _, _ in pts] ys = [v for _, v, _ in pts] n = len(xs) mx, my = sum(xs) / n, sum(ys) / n sxx = sum((x - mx) ** 2 for x in xs) slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sxx if sxx else 0.0 x_now = (now - t0).total_seconds() / 86400 fit_now = my + slope * (x_now - mx) days_to = None bound = None if slope > 0 and hi is not None: days_to, bound = (hi - fit_now) / slope, "max" elif slope < 0 and lo is not None: days_to, bound = (lo - fit_now) / slope, "min" out_n = sum(1 for _, _, o in pts if o) span = max(1.0, xs[-1] - xs[0]) risk = (days_to is not None and days_to <= horizon) or out_n > 0 out.append({"equipment_id": eid, "equipment": names.get(eid, ""), "item": text, "unit": unit or "", "min": lo, "max": hi, "n": n, "last": ys[-1], "last_at": config.iso(pts[-1][0]), "trend_per_30d": round(slope * 30, 3), "fit_now": round(fit_now, 3), "bound": bound, "days_to_bound": None if days_to is None else round(max(0.0, days_to), 1), "out_of_range": out_n, "nu_per_30d": round(out_n / span * 30, 3), "risk": risk}) out.sort(key=lambda r: (not r["risk"], r["days_to_bound"] if r["days_to_bound"] is not None else 1e9, -r["out_of_range"])) return out # ---------- выгрузки ---------- def _fmt(dt): return dt.strftime("%Y-%m-%d %H:%M") if dt else "" def _num(v): return "" if v is None else str(round(v, 3)).replace(".", ",") def export_failures(db: Session, start: datetime, end: datetime, equipment_ids: set[int] | None) -> str: """По одной строке на отказ или неисправность за период — для расчётов в Excel или Python.""" eqs = {e.id: e for e in db.scalars(select(Equipment))} locs = dict(db.execute(select(Location.id, Location.name)).all()) deps = dict(db.execute(select(Department.id, Department.name)).all()) units = {u.id: u.name for u in db.scalars(select(ServiceUnit))} buf = io.StringIO() w = csv.writer(buf, delimiter=";") w.writerow(["№", "Машина", "Инв. №", "Место", "Отдел", "Тип", "Статус", "Источник", "Зарегистрировано", "Переведено в отказ", "Начало работ", "Ввод в эксплуатацию", "Закрыто", "Простой, ч", "Ремонт, ч", "Ожидание запчастей, ч", "Наработка при регистрации", "Наработка при отказе", "Показание при вводе", "Узел-причина", "Причина", "Характер", "Что сделано", "Заменённые узлы", "Признаки заранее", "Что случилось", "Описание устранения"]) q = select(Incident).where(Incident.occurred_at >= start, Incident.occurred_at < end).order_by(Incident.occurred_at) for i in db.scalars(q): if equipment_ids is not None and i.equipment_id not in equipment_ids: continue e = eqs.get(i.equipment_id) fa = failure_at(i) down = (i.restored_at - fa).total_seconds() / 3600 if i.restored_at and i.kind == "incident" else None rep = (i.restored_at - i.repair_started_at).total_seconds() / 3600 if i.restored_at and i.repair_started_at else None w.writerow([i.id, e.name if e else "", e.inventory_no if e else "", locs.get(e.location_id, "") if e else "", deps.get(e.department_id, "") if e else "", "отказ" if i.kind == "incident" else "неисправность", "открыт" if i.status == "open" else "закрыт", {"staff": "сотрудник", "public": "публичная форма", "api": "внешняя система"}.get(i.source, i.source), _fmt(i.occurred_at), _fmt(i.escalated_at), _fmt(i.repair_started_at), _fmt(i.restored_at), _fmt(i.closed_at), _num(down), _num(rep), _num(i.parts_wait_hours), _num(i.hours_at_open), _num(i.hours_at_failure), _num(i.meter_at_restore), units.get(i.cause_unit_id, "") or i.cause_unit_other, CAUSE_RU.get(i.cause, ""), NATURE_RU.get(i.nature, ""), ACTION_RU.get(i.action, ""), ", ".join(u.unit_name for u in i.units if u.replaced), PRECURSOR_RU.get(i.precursor, ""), i.title + (": " + i.description if i.description else ""), i.close_note]) return "" + buf.getvalue() def export_service_events(db: Session, start: datetime, end: datetime, equipment_ids: set[int] | None) -> str: """История обслуживания узлов: плановые замены — «цензурированные» наблюдения (узел заменили, не дождавшись отказа).""" eqs = {e.id: e for e in db.scalars(select(Equipment))} units = {u.id: u for u in db.scalars(select(ServiceUnit))} buf = io.StringIO() w = csv.writer(buf, delimiter=";") w.writerow(["Машина", "Инв. №", "Узел", "Когда", "Наработка машины, м/ч", "Вид", "Наряд", "Комментарий"]) q = select(ServiceEvent).where(ServiceEvent.at >= start, ServiceEvent.at < end).order_by(ServiceEvent.unit_id, ServiceEvent.at) for ev in db.scalars(q): u = units.get(ev.unit_id) if not u or (equipment_ids is not None and u.equipment_id not in equipment_ids): continue e = eqs.get(u.equipment_id) w.writerow([e.name if e else "", e.inventory_no if e else "", u.name, _fmt(ev.at), _num(ev.hours), SERVICE_KIND_RU.get(ev.kind, ev.kind), ev.order_id or "", ev.note]) return "" + buf.getvalue()