Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+301
View File
@@ -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()