Files
core/internal/repository/analytics/analytics.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)
}