---
title: 'Time Series Visualization'
source: 'https://academia.sh/en/courses/visualization-reporting-and-analysis-at-scale/time-series-visualization'
course: 'Visualization, Reporting and Analysis at Scale'
language: en
updated: '2026-10-07T04:31:14+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Builds the position channel on the time axis as an encoder and a decoder and writes the reading resolution as a number in rows'
    - 'Encodes the same series with two axis scales and compares the read-back error per point and the shortest window in which the slope can be read back'
    - 'Measures the slope of the seasonal component itself and shows that the trend is read back only after the component is separated'
---

# Time Series Visualization

The same 36-period series is encoded at the same height with two axis scales: on the axis that starts from zero the reading resolution is 2.273 m³ and the mean read-back error per point is 0.540 m³, on the truncated axis 0.909 m³ and 0.246 m³. At a precision of 0.05 m³/period the slope is read back only from a window of 24 periods on the axis that starts from zero and from a window of 16 periods on the truncated axis. The seasonal component has a slope of its own and changes sign with the start of the window: the same 24-period length gives -0.2239, -0.0487 and -0.2260 m³/period in three separate places, while the trend put into the fictional series is -0.140.

The previous four lessons encoded a number with four channels and measured what each reads back, all
assuming a single snapshot. When the axis itself is **time**, the reader reads back not a value but
the **change** of the value, and what is read is not a length but a **slope**.

The measure of this lesson is the read-back of the slope, and two questions are asked: what happens
to the slope read back when the same series is encoded at the same height with two axis spans, and
whether the trend can be read back without separating the seasonal component. The resampling decision
was measured in the dataframes topic of the Data Analysis with Python course and is not repeated; the
series is taken as given and only its display is measured.

- **CD26. What is read on the time axis is the slope.** The position difference between two points
  divided by the period difference gives the slope; the ratio of the length channel is not read here.
- **CD27. The reading resolution is one row.** The vertical extent of the display is divided into a
  finite number of distinguishable positions; nothing finer than one row can be told apart. The
  resolution is the axis span divided by the number of rows, and every run prints it.
- **CD28. The read-back error of the slope shrinks with the window length.** A slope read from the
  two ends errs by at most the resolution divided by the window length; in a short window the
  resolution swallows the slope.
- **CD29. The seasonal component has a slope of its own.** The component has a zero mean over the
  whole series, but in a finite window its least squares slope is not zero and changes sign
  with where the window starts. The reader reads a single slope, the sum of two components.
- **CD30. The series is fictional.** The three measured periods of the network cannot be extended to
  36 periods; the series was derived with the same seed using the north region mean as the
  base, and its trend and seasonal amplitude are written in the code.

## When the Axis Is a Time

```python
# time_series.py — MODEL: the measurement network of K03-K05 is
# regenerated with the same seed (3,199 rows, 1,329 subscribers, five regions).
# The 36-period series is also FICTIONAL: the three measured periods of the
# network cannot be extended to 36; the series was derived with the same seed
# by taking the north mean as the base. The trend and the seasonal amplitude
# put into it are constant.
import math

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
PERIODS = ["2026-01", "2026-02", "2026-03"]
REGION = [("north", 0.28, 21), ("south", 0.22, 17), ("east", 0.18, 26),
          ("west", 0.14, 14), ("central", 0.18, 23)]


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u, w):
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


_kept, _dropped = [], []
for i in range(RAW):
    r = rng(SEED + i)
    b = REGION[pick(r(), [x[1] for x in REGION])]
    pick(r(), [0.06, 0.24, 0.30, 0.24, 0.11, 0.05])
    pick(r(), [0.08, 0.14, 0.27, 0.34, 0.17])
    (_dropped if r() < 0.046 else _kept).append(
        {"subscriber_id": 10001 + i, "region": b[0], "base": b[2]})
VALID = {a["subscriber_id"] for a in _kept}

READINGS = []
for a in _kept + _dropped:
    r = rng(SEED + 7000 + a["subscriber_id"])
    for d in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        t = 0.0 if r() < 0.038 else math.floor(
            a["base"] * math.exp((r() + r() + r() - 1.5) * 0.62)
            * (1 - d * 0.05) * 100 + 0.5) / 100
        if a["subscriber_id"] not in VALID:
            r()
            continue
        READINGS.append({"subscriber_id": a["subscriber_id"],
                         "region": a["region"], "period": PERIODS[d],
                         "consumption_m3": t})
        r()

north = [o["consumption_m3"] for o in READINGS if o["region"] == "north"]
START = sum(north) / len(north)
N, TREND, AMPLITUDE, SEASON = 36, -0.140, 2.20, 12
_g = rng(SEED + 505)
SEASONAL = [AMPLITUDE * math.sin(2 * math.pi * t / SEASON) for t in range(N)]
SERIES = [round(START + TREND * t + SEASONAL[t]
                + (_g() + _g() + _g() - 1.5) * 0.60, 3) for t in range(N)]


def slope(v):
    """Least squares slope, unit: m3 / period."""
    n = len(v)
    mt, mv = (n - 1) / 2, sum(v) / n
    return (sum((i - mt) * (x - mv) for i, x in enumerate(v))
            / sum((i - mt) ** 2 for i in range(n)))


def encode(v, low, high, rows):
    """Encoder: value -> position. Gives the exact row number on the grid."""
    return max(0, min(rows - 1, round((v - low) / (high - low) * (rows - 1))))


def decode(k, low, high, rows):
    """Decoder: position -> value. The reader reads the value of the row itself."""
    return low + k / (rows - 1) * (high - low)


def draw(v, low, high, rows):
    pos = [encode(x, low, high, rows) for x in v]
    for s in range(rows - 1, -1, -1):
        print(f"{decode(s, low, high, rows):5.1f} |"
              + "".join("*" if k == s else " " for k in pos))
    print("      +" + "-" * len(v))
    return pos


print(f"north region {len(north)} readings, mean {START:.3f} m3")
print(f"fictional series: {N} periods, trend put in {TREND:+.3f} m3/period, "
      f"season length {SEASON} periods, amplitude {AMPLITUDE:.2f} m3")
print(f"series minimum {min(SERIES):.2f}, maximum {max(SERIES):.2f} m3")
print(f"series own slope {slope(SERIES):+.4f} m3/period")
```

```
north region 921 readings, mean 20.210 m3
fictional series: 36 periods, trend put in -0.140 m3/period, season length 12 periods, amplitude 2.20 m3
series minimum 13.00, maximum 22.02 m3
series own slope -0.1797 m3/period
```

The last two lines carry the tension of the lesson. The trend put into the fictional series is -0.140 m³
per period, written in the code; the slope of the series itself comes out as -0.1797. The 0.0397
difference comes not from the noise but from the slope of the seasonal component itself, printed
separately in the third section. It appears before any display is built, at the level of numbers
alone.

## The Same Series, Two Axis Scales

```python
# axis.py — the same series is encoded at the same height with two axis
# scales. The reading resolution is one row: the reader cannot distinguish a
# position finer than a row on the grid.
ROWS, PRECISION = 12, 0.05
SOURCE = slope(SERIES)
result = {}
for name, low, high in (("zero-based", 0.0, 25.0), ("truncated", 13.0, 23.0)):
    c = (high - low) / (ROWS - 1)
    print(f"{name} axis: {low:.0f}-{high:.0f} m3, {ROWS} rows, "
          f"reading resolution {c:.3f} m3/row")
    pos = draw(SERIES, low, high, ROWS)
    back = [decode(k, low, high, ROWS) for k in pos]
    err = sum(abs(back[i] - SERIES[i]) for i in range(N)) / N
    print(f"mean read-back error of {N} points {err:.3f} m3; "
          f"the series uses {len(set(pos))} of the {ROWS} rows")
    result[name] = back
    print("")

print(f"slope window  zero-based  truncated  (window count)  "
      f"precision {PRECISION:.2f} m3/period")
for w in (4, 6, 9, 12, 15, 18, 24):
    count = []
    for name in ("zero-based", "truncated"):
        g = result[name]
        count.append(sum(1 for i in range(N - w + 1)
                         if abs(slope(g[i:i + w]) - slope(SERIES[i:i + w])) <= PRECISION))
    print(f"{w:14d} {count[0]:9d} {count[1]:10d} {N - w + 1:18d}")
for name in ("zero-based", "truncated"):
    g = result[name]
    full = [w for w in range(2, N + 1)
            if all(abs(slope(g[i:i + w]) - slope(SERIES[i:i + w])) <= PRECISION
                   for i in range(N - w + 1))]
    shortest = next(w for w in full if all(v in full for v in range(w, N + 1)))
    print(f"on the {name} axis the slope is read back everywhere, at {PRECISION:.2f} "
          f"precision, only from a window of {shortest} periods")
```

```
zero-based axis: 0-25 m3, 12 rows, reading resolution 2.273 m3/row
 25.0 |                                    
 22.7 | ** *                               
 20.5 |*  * *        ***                   
 18.2 |      **   ***   **     ******      
 15.9 |        ***        *****      **    
 13.6 |                                ****
 11.4 |                                    
  9.1 |                                    
  6.8 |                                    
  4.5 |                                    
  2.3 |                                    
  0.0 |                                    
      +------------------------------------
mean read-back error of 36 points 0.540 m3; the series uses 5 of the 12 rows

truncated axis: 13-23 m3, 12 rows, reading resolution 0.909 m3/row
 23.0 |                                    
 22.1 |  *                                 
 21.2 | * **          *                    
 20.3 |*    *        * *                   
 19.4 |      *      *   *         *        
 18.5 |       *    *             *         
 17.5 |           *      *      *  **      
 16.6 |        ***        *    *           
 15.7 |                    * **      **    
 14.8 |                     *              
 13.9 |                                * **
 13.0 |                                 *  
      +------------------------------------
mean read-back error of 36 points 0.246 m3; the series uses 11 of the 12 rows

slope window  zero-based  truncated  (window count)  precision 0.05 m3/period
             4         4         13                 33
             6         3         19                 31
             9        13         21                 28
            12        13         22                 25
            15        15         20                 22
            18        14         19                 19
            24        13         13                 13
on the zero-based axis the slope is read back everywhere, at 0.05 precision, only from a window of 24 periods
on the truncated axis the slope is read back everywhere, at 0.05 precision, only from a window of 16 periods
```

The two displays encode the same 36 numbers at the same height of 12 rows; only the axis span
differs. On the axis that starts from zero the series uses just **5** of the 12 rows, on the
truncated axis **11**. The mean read-back error per point is the numerical counterpart: 0.540 m³
against 0.246 m³. No number changed; the same numbers sat on a finer grid.

The second table is the real measure. The slope written into the report is wanted correct to a
precision of 0.05 m³/period, a threshold chosen because it is a tenth of the total change over three
years. In a four-period window the axis that starts from zero hits the precision in only 4 of 33
windows, the truncated axis in 13; at six periods the difference is sharpest, 3 against 19. Hitting the
threshold in every window takes **24 periods** on the axis that starts from zero and **16 periods** on
the truncated axis.

The basic charts lesson of the Analysis with Spreadsheets course measured axis truncation on the
length channel, showing it detaches the bar ratio from the data. Here the
same operation works in reverse: the length channel reads a ratio to the baseline, so moving
the baseline breaks the ratio; the position channel reads the **difference** between two positions,
independent of the baseline. The same decision taking the opposite sign in two channels shows that
what decides is not the decision but the channel.

## The Season's Own Slope

```python
# season.py — the seasonal component has a slope of its own, and it changes
# sign according to where the window starts.
step = [SERIES[i + 1] - SERIES[i] for i in range(N - 1)]
print(f"of {len(step)} neighboring steps, {sum(1 for a in step if a > 0)} have a "
      f"sign opposite to the trend")
print(f"{'window':12s}{'series slope':>15s}{'season slope':>16s}{'trend share':>13s}")
for start in (0, 6, 12):
    p = slice(start, start + 24)
    print(f"{start:2d}-{start + 24:<2d} period {slope(SERIES[p]):+14.4f} "
          f"{slope(SEASONAL[p]):+15.4f} {TREND:+12.3f}")


def smooth(v, p):
    """Centered moving average; for an even length the end values count as half."""
    y = p // 2
    return [(sum(v[i - y + 1:i + y]) + (v[i - y] + v[i + y]) / 2) / p
            for i in range(y, len(v) - y)]


flat = smooth(SERIES, SEASON)
flat_step = [flat[i + 1] - flat[i] for i in range(len(flat) - 1)]
print(f"after the season is separated, {len(flat)} periods remain; "
      f"{sum(1 for a in flat_step if a > 0)} of the {len(flat_step)} steps are reversed")
print(f"smoothed series slope {slope(flat):+.4f}, raw slope of the same window "
      f"{slope(SERIES[6:30]):+.4f}, trend put into the fictional series {TREND:+.3f}")
```

```
of 35 neighboring steps, 17 have a sign opposite to the trend
window         series slope    season slope  trend share
 0-24 period        -0.2239         -0.0857       -0.140
 6-30 period        -0.0487         +0.0857       -0.140
12-36 period        -0.2260         -0.0857       -0.140
after the season is separated, 24 periods remain; 0 of the 23 steps are reversed
smoothed series slope -0.1347, raw slope of the same window -0.0487, trend put into the fictional series -0.140
```

The first line gives the display's most misleading side: 17 of the 35 neighboring steps point
against the trend. A reader who reads direction from two neighboring periods flips a coin.

The middle table separates the cause. All three windows are 24 periods long and carry the same trend,
but the slope of the series comes out as -0.2239, -0.0487 and -0.2260. The third column gives the
source of the difference: the least squares slope of the seasonal component itself is
-0.0857 in the window that starts from period 0 and **+0.0857** in the one that starts from period 6.
The component has a zero mean over the whole series yet contributes to the slope in a finite window,
and its sign depends on where the window starts within the season.

This is a property of the series, not of the display; no axis setting can correct it, so the
separation must come before the display. The last two lines are its result: with a moving
average centered at the season length, none of the 23 steps keeps a reversed sign, and the slope read
back becomes -0.1347. The raw slope of the same window was -0.0487; smoothing removed a deviation of
0.0913. The price is 12 periods, six dropped from each end of the series.

## Resolution and Season Bind Together

```python
# both.py — the resolution and the season bind not separately but together.
print("window  axis only  season only  both together")
trunc = result["truncated"]
td = smooth(trunc, SEASON)
sd = smooth(SERIES, SEASON)
for w in (4, 6, 9, 12, 18):
    a = sum(1 for i in range(N - w + 1)
            if abs(slope(trunc[i:i + w]) - TREND) <= PRECISION) / (N - w + 1)
    b = sum(1 for i in range(len(sd) - w + 1)
            if abs(slope(sd[i:i + w]) - TREND) <= PRECISION) / (len(sd) - w + 1)
    c = sum(1 for i in range(len(td) - w + 1)
            if abs(slope(td[i:i + w]) - TREND) <= PRECISION) / (len(td) - w + 1)
    print(f"{w:7d} {a * 100:14.1f}% {b * 100:15.1f}% {c * 100:14.1f}%")
print(f"trend in the source {TREND:+.3f} m3/period; the rates above are how often "
      f"the window gives this number at {PRECISION:.2f} precision")
```

```
window  axis only  season only  both together
      4            3.0%           100.0%           85.7%
      6            0.0%           100.0%           89.5%
      9            7.1%           100.0%          100.0%
     12            4.0%           100.0%          100.0%
     18          100.0%           100.0%          100.0%
trend in the source -0.140 m3/period; the rates above are how often the window gives this number at 0.05 precision
```

The previous table compared the slope read back with **the slope of the window itself**; this one
compares it with the trend put into the fictional series, and its columns correspond to
three separate corrections.

The first column shows the insufficiency of the axis setting: the truncated axis does not give the
trend in **any** window of six periods, and in a nine-period window gives it in two of 28 windows. The
axis resolution corrects the positions of the points, but not the share the season adds to the slope.
The second column shows the opposite: when the season is separated, even a four-period window hits the
precision. The third column writes the price of the two together — the series encoded on the
truncated axis and then smoothed starts at 85.7% and gives 100% from nine periods on. The remaining
shortfall comes from quantization: every point is rounded to the row grid, and in a short window this
rounding reaches the slope even after smoothing.

The decision rule comes from here: a time series display **cannot be reported** without the
**reading resolution of the axis** and the **window length over which the slope is read** written next
to it. If either is missing, the slope the reader sees is not the trend in the data.

## Summary

- What is read on the time axis is not length but slope; the reading resolution is one row, written
  as the axis span divided by the number of rows.
- With the same series at the same height and two axis spans, the mean read-back error per point is
  0.540 m³ and 0.246 m³; the axis that starts from zero uses 5 of the 12 rows.
- At a precision of 0.05 m³/period the slope is read back in every window only from 24 periods on the
  axis that starts from zero and 16 periods on the truncated axis.
- The seasonal component's own slope changes sign between -0.0857 and +0.0857 with the start of the
  window; the same 24-period length gives -0.2239, -0.0487 and -0.2260 m³/period in three places.
- When the season is separated with a centered moving average, none of the 23 steps is left reversed
  and the slope read back becomes -0.1347; the price is 12 periods, and axis truncation cannot do
  this alone.

## Next Step

This lesson turned the axis into time and measured that the channel carries change. Every reading of
the measurement network is also taken at a place, a two-dimensional area. The next lesson models the
regions as a grid and measures that a painted-area display reads back not a value but an **area**: the
small value of a large region suppresses the large value of a small region, and the error is counted
in proportion to the number of cells the region covers. Printing the same grid on a plane stretches
it, and the stretching moves the number read back a third time.
