405 lines
9.4 KiB
Go
405 lines
9.4 KiB
Go
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
type Repository struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewRepository(db *sql.DB) *Repository {
|
|
return &Repository{db: db}
|
|
}
|
|
|
|
type LotMetrics struct {
|
|
LotID *int64
|
|
LotCode *string
|
|
Failures int64
|
|
ComponentCount int64
|
|
ExposureSeconds float64
|
|
ExposureHours float64
|
|
ExposureYears float64
|
|
AFR float64
|
|
MTBFHours *float64
|
|
}
|
|
|
|
type FirmwareRisk struct {
|
|
FirmwareVersion string
|
|
Failures int64
|
|
ComponentCount int64
|
|
FailureRate *float64
|
|
}
|
|
|
|
type SpareForecast struct {
|
|
LotID *int64
|
|
LotCode *string
|
|
ComponentCount int64
|
|
AFR float64
|
|
ExpectedFailures float64
|
|
SparesNeeded int64
|
|
}
|
|
|
|
func (r *Repository) ListLotMetrics(ctx context.Context, start, end time.Time) ([]LotMetrics, error) {
|
|
exposures, err := r.loadLotExposures(ctx, start, end)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
failures, err := r.loadLotFailures(ctx, start, end)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
metrics := map[string]*LotMetrics{}
|
|
for key, exposure := range exposures {
|
|
metrics[key] = exposure
|
|
}
|
|
for key, failure := range failures {
|
|
entry, ok := metrics[key]
|
|
if !ok {
|
|
entry = &LotMetrics{
|
|
LotID: failure.LotID,
|
|
LotCode: failure.LotCode,
|
|
}
|
|
metrics[key] = entry
|
|
}
|
|
entry.Failures = failure.Failures
|
|
}
|
|
|
|
items := make([]LotMetrics, 0, len(metrics))
|
|
for _, metric := range metrics {
|
|
metric.ExposureHours = metric.ExposureSeconds / 3600
|
|
metric.ExposureYears = metric.ExposureSeconds / (3600 * 24 * 365)
|
|
if metric.ExposureYears > 0 {
|
|
metric.AFR = float64(metric.Failures) / metric.ExposureYears
|
|
}
|
|
if metric.Failures > 0 && metric.ExposureHours > 0 {
|
|
value := metric.ExposureHours / float64(metric.Failures)
|
|
metric.MTBFHours = &value
|
|
}
|
|
items = append(items, *metric)
|
|
}
|
|
|
|
sort.Slice(items, func(i, j int) bool {
|
|
if items[i].LotCode == nil && items[j].LotCode != nil {
|
|
return false
|
|
}
|
|
if items[i].LotCode != nil && items[j].LotCode == nil {
|
|
return true
|
|
}
|
|
if items[i].LotCode != nil && items[j].LotCode != nil && *items[i].LotCode != *items[j].LotCode {
|
|
return *items[i].LotCode < *items[j].LotCode
|
|
}
|
|
if items[i].LotID == nil && items[j].LotID != nil {
|
|
return false
|
|
}
|
|
if items[i].LotID != nil && items[j].LotID == nil {
|
|
return true
|
|
}
|
|
if items[i].LotID != nil && items[j].LotID != nil {
|
|
return *items[i].LotID < *items[j].LotID
|
|
}
|
|
return false
|
|
})
|
|
|
|
return items, nil
|
|
}
|
|
|
|
func (r *Repository) ListFirmwareRisk(ctx context.Context, start, end time.Time) ([]FirmwareRisk, error) {
|
|
failures, err := r.loadFirmwareFailures(ctx, start, end)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
components, err := r.loadFirmwareComponents(ctx, start, end)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
items := make([]FirmwareRisk, 0, len(components))
|
|
seen := map[string]bool{}
|
|
for firmware, count := range components {
|
|
entry := FirmwareRisk{
|
|
FirmwareVersion: firmware,
|
|
ComponentCount: count,
|
|
Failures: failures[firmware],
|
|
}
|
|
if entry.ComponentCount > 0 {
|
|
value := float64(entry.Failures) / float64(entry.ComponentCount)
|
|
entry.FailureRate = &value
|
|
}
|
|
items = append(items, entry)
|
|
seen[firmware] = true
|
|
}
|
|
for firmware, count := range failures {
|
|
if seen[firmware] {
|
|
continue
|
|
}
|
|
entry := FirmwareRisk{
|
|
FirmwareVersion: firmware,
|
|
ComponentCount: 0,
|
|
Failures: count,
|
|
}
|
|
items = append(items, entry)
|
|
}
|
|
|
|
sort.Slice(items, func(i, j int) bool {
|
|
return items[i].FirmwareVersion < items[j].FirmwareVersion
|
|
})
|
|
|
|
return items, nil
|
|
}
|
|
|
|
func (r *Repository) ForecastSpare(ctx context.Context, start, end time.Time, horizonDays int, multiplier float64) ([]SpareForecast, error) {
|
|
metrics, err := r.ListLotMetrics(ctx, start, end)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
horizonYears := float64(horizonDays) / 365
|
|
items := make([]SpareForecast, 0, len(metrics))
|
|
for _, metric := range metrics {
|
|
expected := metric.AFR * float64(metric.ComponentCount) * horizonYears
|
|
if expected < 0 {
|
|
expected = 0
|
|
}
|
|
spares := int64(math.Ceil(expected * multiplier))
|
|
items = append(items, SpareForecast{
|
|
LotID: metric.LotID,
|
|
LotCode: metric.LotCode,
|
|
ComponentCount: metric.ComponentCount,
|
|
AFR: metric.AFR,
|
|
ExpectedFailures: expected,
|
|
SparesNeeded: spares,
|
|
})
|
|
}
|
|
|
|
return items, nil
|
|
}
|
|
|
|
type lotMetricsRow struct {
|
|
LotID *int64
|
|
LotCode *string
|
|
Failures int64
|
|
ComponentCount int64
|
|
ExposureSeconds float64
|
|
}
|
|
|
|
func (r *Repository) loadLotExposures(ctx context.Context, start, end time.Time) (map[string]*LotMetrics, error) {
|
|
rows, err := r.db.QueryContext(ctx, `
|
|
SELECT l.id, l.code,
|
|
COUNT(DISTINCT i.component_id) AS component_count,
|
|
SUM(GREATEST(0, TIMESTAMPDIFF(SECOND,
|
|
GREATEST(i.installed_at, ?),
|
|
LEAST(COALESCE(i.removed_at, ?), ?)
|
|
))) AS exposure_seconds
|
|
FROM installations i
|
|
JOIN components c ON c.id = i.component_id
|
|
LEFT JOIN lots l ON l.id = c.lot_id
|
|
WHERE i.installed_at <= ?
|
|
AND (i.removed_at IS NULL OR i.removed_at >= ?)
|
|
GROUP BY l.id, l.code
|
|
ORDER BY l.code, l.id`,
|
|
start, end, end,
|
|
end, start,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
metrics := map[string]*LotMetrics{}
|
|
for rows.Next() {
|
|
var lotID sql.NullInt64
|
|
var lotCode sql.NullString
|
|
var componentCount sql.NullInt64
|
|
var exposureSeconds sql.NullFloat64
|
|
|
|
if err := rows.Scan(&lotID, &lotCode, &componentCount, &exposureSeconds); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entry := LotMetrics{
|
|
LotID: nullInt64Ptr(lotID),
|
|
LotCode: nullStringPtr(lotCode),
|
|
ComponentCount: nullInt64Value(componentCount),
|
|
ExposureSeconds: nullFloatValue(exposureSeconds),
|
|
}
|
|
metrics[lotKey(entry.LotID, entry.LotCode)] = &entry
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return metrics, nil
|
|
}
|
|
|
|
func (r *Repository) loadLotFailures(ctx context.Context, start, end time.Time) (map[string]lotMetricsRow, error) {
|
|
rows, err := r.db.QueryContext(ctx, `
|
|
SELECT l.id, l.code, COUNT(*) AS failures
|
|
FROM failure_events f
|
|
JOIN components c ON c.id = f.component_id
|
|
LEFT JOIN lots l ON l.id = c.lot_id
|
|
WHERE f.failure_time >= ? AND f.failure_time <= ?
|
|
GROUP BY l.id, l.code
|
|
ORDER BY l.code, l.id`,
|
|
start, end,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
metrics := map[string]lotMetricsRow{}
|
|
for rows.Next() {
|
|
var lotID sql.NullInt64
|
|
var lotCode sql.NullString
|
|
var failures sql.NullInt64
|
|
|
|
if err := rows.Scan(&lotID, &lotCode, &failures); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entry := lotMetricsRow{
|
|
LotID: nullInt64Ptr(lotID),
|
|
LotCode: nullStringPtr(lotCode),
|
|
Failures: nullInt64Value(failures),
|
|
}
|
|
metrics[lotKey(entry.LotID, entry.LotCode)] = entry
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return metrics, nil
|
|
}
|
|
|
|
func (r *Repository) loadFirmwareFailures(ctx context.Context, start, end time.Time) (map[string]int64, error) {
|
|
rows, err := r.db.QueryContext(ctx, `
|
|
SELECT o.firmware_version
|
|
FROM failure_events f
|
|
LEFT JOIN observations o
|
|
ON o.component_id = f.component_id
|
|
AND o.observed_at = (
|
|
SELECT MAX(o2.observed_at)
|
|
FROM observations o2
|
|
WHERE o2.component_id = f.component_id
|
|
AND o2.observed_at <= f.failure_time
|
|
)
|
|
WHERE f.failure_time >= ? AND f.failure_time <= ?`,
|
|
start, end,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
failures := map[string]int64{}
|
|
for rows.Next() {
|
|
var firmware sql.NullString
|
|
if err := rows.Scan(&firmware); err != nil {
|
|
return nil, err
|
|
}
|
|
key := firmwareValue(firmware)
|
|
failures[key]++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return failures, nil
|
|
}
|
|
|
|
func (r *Repository) loadFirmwareComponents(ctx context.Context, start, end time.Time) (map[string]int64, error) {
|
|
rows, err := r.db.QueryContext(ctx, `
|
|
SELECT o.component_id, o.firmware_version
|
|
FROM observations o
|
|
JOIN (
|
|
SELECT component_id, MAX(observed_at) AS observed_at
|
|
FROM observations
|
|
WHERE observed_at >= ? AND observed_at <= ?
|
|
GROUP BY component_id
|
|
) latest
|
|
ON latest.component_id = o.component_id
|
|
AND latest.observed_at = o.observed_at`,
|
|
start, end,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
components := map[string]int64{}
|
|
for rows.Next() {
|
|
var componentID sql.NullInt64
|
|
var firmware sql.NullString
|
|
if err := rows.Scan(&componentID, &firmware); err != nil {
|
|
return nil, err
|
|
}
|
|
key := firmwareValue(firmware)
|
|
components[key]++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return components, nil
|
|
}
|
|
|
|
func lotKey(id *int64, code *string) string {
|
|
if id == nil && code == nil {
|
|
return "unknown"
|
|
}
|
|
if id == nil {
|
|
return "code:" + *code
|
|
}
|
|
if code == nil {
|
|
return "id:" + int64ToString(*id)
|
|
}
|
|
return "id:" + int64ToString(*id) + "|code:" + *code
|
|
}
|
|
|
|
func firmwareValue(value sql.NullString) string {
|
|
if value.Valid && value.String != "" {
|
|
return value.String
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func nullInt64Ptr(value sql.NullInt64) *int64 {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
v := value.Int64
|
|
return &v
|
|
}
|
|
|
|
func nullStringPtr(value sql.NullString) *string {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
v := value.String
|
|
return &v
|
|
}
|
|
|
|
func nullInt64Value(value sql.NullInt64) int64 {
|
|
if !value.Valid {
|
|
return 0
|
|
}
|
|
return value.Int64
|
|
}
|
|
|
|
func nullFloatValue(value sql.NullFloat64) float64 {
|
|
if !value.Valid {
|
|
return 0
|
|
}
|
|
return value.Float64
|
|
}
|
|
|
|
func int64ToString(value int64) string {
|
|
return strconv.FormatInt(value, 10)
|
|
}
|