Skip to content

Lesson 07 - Analytics

Objective

Characterise a fleet's governed KPI history - is it normal, is it drifting, how confident are we - without ever re-deriving the KPI itself.

Prerequisites

  • Lesson 06 - KPIs (analytics consumes governed KPI results; you must know what makes them governed)

Concepts covered

Concept Why it exists
TimeSeries / TimeSeriesPoint A sequence of governed facts over time.
describe()StatisticalSummary n, mean, std, minimum, maximum, percentiles - the spread, not just the average.
LinearTrendModelTrendResult Turns "looks worse" into direction, slope, r_squared.
No re-derivation Analytics reads KPIResults. It never recomputes tonnes ÷ hours.
Interface-only forecasting ForecastingModel ships zero implementations, on purpose.

Complete runnable example

examples/fundamentals/07_analytics/analytics.py

python examples/fundamentals/07_analytics/analytics.py

Every point comes out of the KPI object - analytics derives nothing:

from mineproductivity.analytics import (
    AnalyticsContext, LinearTrendModel, TimeSeries, TimeSeriesPoint, TrendResult, describe,
)
from mineproductivity.events.store import _InMemoryEventStore
from mineproductivity.kpis import REGISTRY

tph_kpi = REGISTRY.get("PROD.TPH")()
points = [
    TimeSeriesPoint(timestamp=..., value=tph_kpi.compute([{...}]).value)
    for tonnes, hours in SHIFT_LOG
]
series = TimeSeries(points=tuple(points))

summary = describe(series)

# analyze() is the PUBLIC contract. It returns the AnalyticsResult family,
# so narrow to TrendResult to read slope / direction / r_squared.
analysed = LinearTrendModel().analyze(
    series, context=AnalyticsContext(event_store=_InMemoryEventStore())
)
assert isinstance(analysed, TrendResult)
trend = analysed

Expected output

--- 1. Start from GOVERNED facts, not raw arithmetic ---
built a 14-shift TimeSeries of PROD.TPH for FL-NORTH
first shift 1,300.0 t/h -> last shift 1,150.0 t/h
(every point came out of the KPI object -- analytics re-derived nothing)

--- 2. describe(): characterise the distribution ---
n       : 14 shifts
mean    : 1,234.3 t/h
std     : 50.8 t/h
range   : 1,150.0 -> 1,310.0 t/h
p50     : 1,235.0 t/h
p90     : 1,297.0 t/h
(a mean alone hides the story; the spread is the operational question)

--- 3. A trend model turns 'looks worse' into a defensible judgement ---
model     : TREND.Linear
direction : decreasing
r_squared : 0.9740
slope     : -0.000288 t/h per SECOND  <-- the raw fit unit
            = -12.4 t/h per 12 h shift  <-- what a human asks for

reading   : FL-NORTH is losing ~12.4 t/h every shift
            (~162 t/h across the 14-shift window),
            and r^2 = 0.974 says a straight line explains
            the decline well -- this is degradation, not noise.

--- 4. Trend characterises the observed window. It does NOT forecast ---
window characterised: 14 shifts
LinearTrendModel describes what HAPPENED. It will not extrapolate,
and the package ships no forecasting algorithm at all:
  analytics.ForecastingModel is an interface with ZERO implementations.
  ADR-0006 rejected shipping one -- choosing exponential smoothing vs
  ARIMA vs anything else is a modelling decision the platform refuses
  to make on your behalf. A forecasting plugin registers against that
  stable contract instead.

--- 5. Why this layering matters ---
PROD.TPH said WHAT the rate was (governed, one definition, one unit).
Analytics said whether it is DRIFTING (characterisation, no re-derivation).
Neither decided what to DO about it -- that is the decision layer, next.

Explanation

Characterise, never re-derive

This is the layer's whole discipline. If PROD.TPH says 1,150 t/h, analytics takes that number. It does not open the event store and recompute tonnes ÷ hours with its own slightly-different idea of operating hours. The moment it did, you would have two definitions of TPH in one system and no way to reconcile a report.

So the flow is strictly one-directional: kpis produces governed facts → analytics characterises them.

The mean is the least interesting number

Section 2 reports mean = 1,234.3 t/h. On its own that looks healthy against a 1,250 target - barely under. But range = 1,150 → 1,310 and std = 50.8 tell the real story: the fleet is not sitting at 1,234, it is travelling from 1,310 down to 1,150. The mean is an artefact of a trend, and reporting it alone would hide a degrading fleet behind a reassuring average.

That is why describe() returns a distribution, not a number.

Mind the units - the slope trap

LinearTrendModel fits against elapsed seconds (trend.py computes x = (timestamp - origin).total_seconds()). So slope is in t/h per second: -0.000288.

Printed raw at two decimal places that reads -0.00 - which looks exactly like "no trend at all", while r² = 0.974 is simultaneously screaming that the decline is almost perfectly linear. Those two statements cannot both be true, and noticing the contradiction is the skill.

Convert to the operational unit:

per_shift = trend.slope * 12 * 3600      # -12.4 t/h per 12 h shift
over_window = per_shift * (len(series.points) - 1)   # ~-162 t/h

FL-NORTH is losing about 12.4 t/h every shift. That is a sentence a superintendent can act on. -0.000288 is not.

Why the platform will not forecast for you

analytics.ForecastingModel is an interface with zero implementations, and that is deliberate (ADR-0006). Choosing exponential smoothing over ARIMA over a gradient-boosted model is a modelling decision that depends on your data, your horizon, and your tolerance for being wrong. The platform refuses to make it on your behalf and pretend it is a framework concern.

Note also that LinearTrendModel characterises the observed window only. It will not extrapolate. A trend is a description of what happened, not a promise about next week.

Best practices

  • Always convert the slope to an operational unit before showing a human. Nobody reasons in t/h-per-second.
  • Report the spread, not just the mean. std, minimum, maximum, and percentiles are where the story is.
  • Check r_squared before believing a direction. A decreasing direction with r² = 0.04 is noise wearing a trend's clothes.
  • Feed analytics from governed KPI results, never from raw rows you re-derived yourself.
  • Reach for a plugin when you need forecasting - the contract is already there.

Common mistakes

Mistake What happens Do this instead
Reporting slope raw -0.00 - you conclude "no trend" while the fleet degrades Multiply by 12*3600 for per-shift
Trusting direction alone decreasing can be pure noise Check r_squared too
Reporting only the mean 1,234 looks fine; the fleet is falling from 1,310 to 1,150 Report the distribution
Re-deriving TPH inside analytics Two definitions of one metric; irreconcilable reports Consume KPIResults
Expecting a built-in forecaster ForecastingModel has no implementations Register a plugin
AnalyticsContext() with no store event_store is required AnalyticsContext(event_store=_InMemoryEventStore())

Exit code 0 is not correctness

This lesson's first draft printed slope: -0.00 t/h per shift and exited 0. It was wrong - mislabelled units - and only reading the output critically caught it. A script that runs is not a script that is right.

Exercises

  1. Do the unit conversion yourself. Take the raw slope and compute the loss per shift, per day, and per 14-shift roster. Which is the number you would put in a shift report?
  2. Manufacture noise. Replace SHIFT_LOG with 14 random values between 1,150 and 1,310. What happens to direction and, crucially, to r_squared? What r² would you personally require before escalating?
  3. Hide a trend behind a mean. Construct a series whose mean is exactly 1,250 (on target) but which falls from 1,400 to 1,100. What does describe() reveal that the mean conceals?
  4. Read the refusal. Open src/mineproductivity/analytics/forecasting.py. Write down, in your own words, the two reasons ADR-0006 gives for not shipping a forecaster. Do you agree?

Suggested next lesson

Lesson 08 - Decision - the fleet is measurably degrading. Now: does that breach policy, what should we do, and could you defend the call in an incident review six months from now?


See also: analytics API Reference · analytics package guide · Analytics Engine design specification · ADR-0006