Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
"""Анализ надёжности оборудования по данным портала.
|
||||
|
||||
Методика — «Надёжность и безопасность» (ИАТЭ, 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()
|
||||
Reference in New Issue
Block a user