Files
tech-service-portal/app/reliability.py
T

302 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Анализ надёжности оборудования по данным портала.
Методика — «Надёжность и безопасность» (ИАТЭ, 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<k} e^(−x/2)(x/2)^i/i! — для чётного числа степеней свободы считается точно."""
m = x / 2
term, s = math.exp(-m), 0.0
for i in range(k):
if i:
term *= m / i
s += term
return s
def chi2_quantile_even(p: float, k: int) -> 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()