Temporal Patterns#

Use global_iter() with math functions to make generated data change shape over the life of a workload. This lets you model real-world patterns like shifting popularity, price inflation, seasonal traffic, periodic spikes, and sensor calibration drift; all without external scripts.

Every pattern on this page uses the same building block: an expression that takes global_iter() as input and produces a value whose distribution or magnitude changes as the iteration counter grows.

The full config is at examples/temporal_patterns/crdb.edg.

Globals Are User-Defined#

Names in the following globals: block are arbitrary and become variables available in any expression. The patterns below use names like total_iters and initial_skew, but you can name them whatever you like.

Patterns#

Zipf Skew Drift#

Product popularity starts nearly uniform and concentrates over time. The zipf.int() skew parameter is linearly interpolated from initial_skew to final_skew using global_iter().

Globals

let total_iters = 10000
let products = 100
let initial_skew = 1.2   # nearly uniform
let final_skew = 3.0     # heavily concentrated on the first product

Expression

zipf.int(initial_skew + (final_skew - initial_skew) * global_iter() / total_iters, 1, products - 1)

Config

insert_view(type: exec) `INSERT INTO product_views (product_id, skew_at_time, iteration)
  VALUES ($1::INT, $2::FLOAT8, $3::INT8)` (
  zipf.int(initial_skew + (final_skew - initial_skew) * global_iter() / total_iters, 1, products - 1),
  initial_skew + (final_skew - initial_skew) * global_iter() / total_iters,
  global_iter()
)

Verification query

SELECT
  bucket,
  count(*) AS total,
  count(*) FILTER (WHERE product_id = 0) AS top_product,
  round((count(*) FILTER (WHERE product_id = 0))::FLOAT8 / count(*)::FLOAT8 * 100, 1) AS top_pct,
  repeat('▒', (round((count(*) FILTER (WHERE product_id = 0))::FLOAT8 / count(*)::FLOAT8 * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(10) OVER (ORDER BY iteration) AS bucket
  FROM product_views
)
GROUP BY bucket
ORDER BY bucket;
  bucket | total | top_product | top_pct |                histogram
---------+-------+-------------+---------+-------------------------------------------
       1 |  2644 |        1225 |    46.3 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |  2643 |        1959 |    74.1 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |  2643 |        2440 |    92.3 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |  2643 |        2593 |    98.1 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |  2643 |        2632 |    99.6 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |  2643 |        2637 |    99.8 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |  2643 |        2641 |    99.9 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |  2643 |        2642 |     100 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |  2643 |        2643 |     100 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |  2643 |        2643 |     100 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • initial_skew / final_skew - controls how much the distribution shifts. A smaller gap produces a subtler drift; a larger gap makes it dramatic. Values near 1.0 are nearly uniform, values above 3.0 concentrate almost all traffic on the lowest-ranked items.
  • total_iters - the iteration count at which final_skew is reached. After this point the skew parameter stays at final_skew. Set this to roughly match your expected total iterations (see Estimating total iterations).

Logarithmic Growth#

Prices rise steeply at first then plateau. Models inflation, adoption curves, or any quantity with diminishing returns.

Globals

let base_price = 50.0
let products = 100

Expression

floor(base_price * (1.0 + log(1.0 + global_iter() / 1000.0)) * 100.0) / 100.0

Config

insert_price(type: exec) `INSERT INTO price_history (product_id, price, iteration)
  VALUES ($1::INT, $2::FLOAT8, $3::INT8)` (
  uniform.int(1, products),
  floor(base_price * (1.0 + log(1.0 + global_iter() / 1000.0)) * 100.0) / 100.0,
  global_iter()
)

Verification query

SELECT
  bucket,
  round(avg(price)::NUMERIC, 2) AS avg_price,
  round(min(price)::NUMERIC, 2) AS min_price,
  round(max(price)::NUMERIC, 2) AS max_price,
  repeat('▒', (round(avg(price) / max(avg(price)) OVER () * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(10) OVER (ORDER BY iteration) AS bucket
  FROM price_history
)
GROUP BY bucket
ORDER BY bucket;
  bucket | avg_price | min_price | max_price |                histogram
---------+-----------+-----------+-----------+-------------------------------------------
       1 |    105.75 |     50.09 |    139.66 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |    156.93 |    139.67 |    170.72 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |    185.96 |    170.73 |    200.89 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |    211.99 |    200.90 |    221.88 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |    229.27 |    221.89 |    236.09 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |    242.19 |    236.10 |    247.77 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |    252.52 |    247.78 |    256.94 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |    260.88 |    256.95 |    264.72 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |    268.11 |    264.72 |    271.35 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |    274.40 |    271.35 |    277.37 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • base_price - starting price before any growth.
  • The divisor inside log(1.0 + global_iter() / 1000.0) controls how quickly the curve rises. A smaller divisor (e.g. 100.0) makes it steeper; a larger divisor (e.g. 10000.0) flattens it.
  • floor(... * 100.0) / 100.0 rounds to cents. Remove the floor/division to keep full precision.

Sine Wave Seasonality#

Request counts oscillate around a slowly growing baseline. Models daily/weekly traffic cycles, seasonal patterns, or any periodic signal riding on a trend.

Globals

let traffic_period = 50000
let traffic_amplitude = 100.0
let base_traffic = 100.0

Expression

floor(abs(base_traffic + 0.5 * sqrt(global_iter()) + amplitude * sin(2.0 * pi * global_iter() / period)))

Config

insert_traffic(type: exec) `INSERT INTO traffic_log (endpoint, request_count, iteration)
  VALUES ($1, $2::INT, $3::INT8)` (
  set(['GET /products', 'GET /orders', 'POST /checkout', 'GET /search'], []),
  floor(abs(base_traffic + 0.5 * sqrt(global_iter()) + traffic_amplitude * sin(2.0 * pi * global_iter() / traffic_period))),
  global_iter()
)

Verification query

SELECT
  bucket,
  round(avg(request_count)::NUMERIC, 1) AS avg_requests,
  min(request_count) AS min_requests,
  max(request_count) AS max_requests,
  repeat('▒', (round(avg(request_count) / max(avg(request_count)) OVER () * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(20) OVER (ORDER BY iteration) AS bucket
  FROM traffic_log
)
GROUP BY bucket
ORDER BY bucket;
  bucket | avg_requests | min_requests | max_requests |                histogram
---------+--------------+--------------+--------------+-------------------------------------------
       1 |        149.5 |          101 |          186 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |        212.8 |          186 |          236 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |        248.8 |          236 |          257 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |        253.9 |          247 |          257 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |        232.8 |          215 |          247 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |        192.7 |          170 |          215 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |        147.7 |          127 |          170 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |        111.6 |          100 |          127 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |         96.9 |           96 |          100 | ▒▒▒▒▒▒▒▒▒▒▒▒
      10 |        107.2 |           98 |          121 | ▒▒▒▒▒▒▒▒▒▒▒▒▒
      11 |        142.9 |          121 |          167 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      12 |        199.3 |          167 |          229 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      13 |        256.6 |          229 |          282 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      14 |        301.4 |          283 |          316 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      15 |        322.7 |          316 |          325 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      16 |        315.4 |          303 |          324 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      17 |        282.0 |          258 |          302 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      18 |        233.1 |          209 |          258 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      19 |        186.0 |          167 |          209 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      20 |        154.7 |          148 |          167 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • traffic_period - iterations per full sine cycle. This is the most important parameter. If each bucket in your verification query averages over a full cycle or more, the sine wave averages to zero and you only see the baseline trend. See Matching period to your run.
  • traffic_amplitude - peak deviation from the baseline. Must be large enough relative to the baseline to be visible. If base_traffic is 100 and amplitude is 10, the wave is only a 10% wobble.
  • 0.5 * sqrt(global_iter()) - the growth coefficient on the baseline trend. A larger coefficient makes the upward trend dominate the wave; a smaller one lets the oscillation stand out.
  • abs(...) - prevents negative request counts when the trough of the sine wave dips below zero.

Periodic Spikes#

Severity surges at regular intervals then drops to near zero. Models periodic maintenance windows, batch job impacts, or recurring failure modes.

Expression

pow(cos(pi * mod(global_iter(), interval) / interval), 2.0) * 10.0

cos² produces a smooth pulse: maximum (10.0) when mod(iter, interval) = 0, minimum (0.0) at the midpoint of each interval.

Globals

let spike_interval = 25000

Config

insert_error(type: exec) `INSERT INTO error_events (error_code, severity, iteration)
  VALUES ($1::INT, $2::FLOAT8, $3::INT8)` (
  uniform.int(500, 599),
  pow(cos(pi * mod(global_iter(), spike_interval) / spike_interval), 2.0) * 10.0,
  global_iter()
)

Verification query

SELECT
  bucket,
  round(avg(severity)::NUMERIC, 2) AS avg_severity,
  round(max(severity)::NUMERIC, 2) AS max_severity,
  repeat('▒', (round(avg(severity) / max(avg(severity)) OVER () * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(20) OVER (ORDER BY iteration) AS bucket
  FROM error_events
)
GROUP BY bucket
ORDER BY bucket;
  bucket | avg_severity | max_severity |                histogram
---------+--------------+--------------+-------------------------------------------
       1 |         8.06 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |         1.43 |         4.67 | ▒▒▒▒▒▒
       3 |         2.85 |         6.56 | ▒▒▒▒▒▒▒▒▒▒▒▒
       4 |         9.05 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |         6.34 |         9.58 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |         0.59 |         2.20 | ▒▒▒
       7 |         4.52 |         8.16 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |         9.38 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |         4.34 |         8.12 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |         0.52 |         1.97 | ▒▒
      11 |         5.91 |         9.36 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      12 |         9.21 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      13 |         3.37 |         7.09 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      14 |         1.09 |         3.91 | ▒▒▒▒▒
      15 |         7.41 |         9.92 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      16 |         8.50 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      17 |         2.03 |         5.37 | ▒▒▒▒▒▒▒▒▒
      18 |         1.92 |         5.11 | ▒▒▒▒▒▒▒▒
      19 |         8.37 |        10.00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      20 |         7.42 |         9.96 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • spike_interval - iterations between spike peaks. Same rule as traffic_period: if the interval is shorter than the iteration width of a single bucket, each bucket averages over multiple full cycles and flattens to ~5.0. See Matching period to your run.
  • The * 10.0 multiplier at the end sets the peak severity. Change it to scale the spike magnitude.

Bounded Drift (Arctangent Saturation)#

Sensor readings drift from a true value and asymptotically approach a maximum offset. Models calibration drift, battery degradation, or any quantity that grows quickly then saturates.

Expression (upward drift)

100.0 + (2.0 * atan(sqrt(global_iter()) / 100.0) / pi) * 15.0 + noise

Expression (downward drift)

100.0 - (2.0 * atan(sqrt(global_iter()) / 100.0) / pi) * 15.0 + noise

2 * atan(x) / pi maps any positive input to the range (0, 1). Multiplying by 15 bounds the drift to 0-15 units. The sqrt inside atan gives fast initial movement that slows as it approaches the asymptote. Flip + to - to model decay (e.g. battery degradation) instead of growth.

Config

insert_sensor(type: exec) `INSERT INTO sensor_calibration (sensor_id, reading, drift_offset, iteration)
  VALUES ($1::INT, $2::FLOAT8, $3::FLOAT8, $4::INT8)` (
  uniform.int(1, 50),
  100.0 + (2.0 * atan(sqrt(global_iter()) / 100.0) / pi) * 15.0 + norm.float(0, 0.5, -2, 2, 2),
  2.0 * atan(sqrt(global_iter()) / 100.0) / pi * 15.0,
  global_iter()
)

Verification query

SELECT
  bucket,
  round(avg(drift_offset)::NUMERIC, 3) AS avg_drift,
  round(avg(reading)::NUMERIC, 2) AS avg_reading,
  repeat('▒', (round(avg(drift_offset) / max(avg(drift_offset)) OVER () * 40))::INT) AS histogram
FROM (
  SELECT *, ntile(10) OVER (ORDER BY iteration) AS bucket
  FROM sensor_calibration
)
GROUP BY bucket
ORDER BY bucket;

Upward drift - readings climb from 100 toward ~115:

  bucket | avg_drift | avg_reading |                histogram
---------+-----------+-------------+-------------------------------------------
       1 |     6.068 |      106.06 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |     9.114 |      109.12 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |    10.209 |      110.22 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |    10.853 |      110.86 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |    11.292 |      111.30 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |    11.621 |      111.62 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |    11.866 |      111.87 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |    12.068 |      112.05 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |    12.235 |      112.21 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |    12.376 |      112.37 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Downward drift - readings decay from 100 toward ~85:

  bucket | avg_drift | avg_reading |                histogram
---------+-----------+-------------+-------------------------------------------
       1 |   -12.376 |       87.63 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       2 |   -12.235 |       87.79 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       3 |   -12.068 |       87.95 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       4 |   -11.866 |       88.13 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       5 |   -11.621 |       88.38 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       6 |   -11.292 |       88.70 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       7 |   -10.853 |       89.14 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       8 |   -10.209 |       89.78 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
       9 |    -9.114 |       90.88 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
      10 |    -6.068 |       93.94 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • The * 15.0 multiplier sets the maximum drift (asymptote). Change it to control how far the reading can wander.
  • The / 100.0 inside atan(sqrt(iter) / 100.0) controls how quickly saturation is reached. A smaller divisor reaches the asymptote sooner.
  • norm.float(0, 0.5, -2, 2, 2) adds Gaussian noise to individual readings. Increase the stddev (second arg) for noisier data.

Estimating Total Iterations#

The actual number of global_iter() increments depends on workers, duration, query complexity, and hardware. This matters because periodic parameters (traffic_period, spike_interval) must be tuned relative to the actual iteration count - not the configured total_iters global.

Estimate total iterations from three values:

total_iterations = workers * duration_seconds / avg_latency_seconds
VariableMeaning
workersNumber of concurrent workers (-w flag)
duration_secondsRun duration in seconds (-d flag)
avg_latency_secondsAverage time for one iteration (query execution + overhead)

Example: 10 workers, 60s duration, 5ms average latency:

10 * 60 / 0.005 = 120,000 iterations

This assumes each worker spends all its time executing queries. In practice, connection overhead and GC pauses reduce throughput slightly, so treat the result as an upper bound.

Matching Period to Your Run#

The verification queries use ntile(N) to divide rows into N equal-sized buckets by iteration order. For a periodic pattern to be visible in the histogram, each bucket must cover less than one full cycle. If a bucket spans one or more complete cycles, the periodic component averages out and disappears.

The rule: each bucket’s iteration width should be less than the period.

bucket_width = total_iterations / number_of_buckets

For the pattern to be clearly visible:

period > bucket_width

For 2 visible cycles across all buckets (a good default):

period = total_iterations / 2

Example: with ~100,000 actual iterations and 20 buckets:

  • Each bucket spans ~5,000 iterations
  • traffic_period: 50000 -> 2 full cycles, 10 buckets per cycle - clear wave
  • traffic_period: 5000 -> 20 full cycles, 1 cycle per bucket - wave averages out, histogram looks monotonic
  • traffic_period: 500 -> 200 cycles per bucket - completely flat

The same logic applies to spike_interval. If your histogram looks flat when you expect oscillation, increase the period.

Combining Patterns#

All five patterns run concurrently using run_weights to control the mix:

weights {
  insert_view = 25
  insert_price = 20
  insert_traffic = 25
  insert_error = 15
  insert_sensor = 15
}

Adjust weights to emphasise specific patterns or to control relative table sizes. The weights don’t affect the drift expressions themselves - global_iter() increments globally regardless of which statement executes.


Correlated Multi-Table Signals PRO#

The patterns above work per-expression. Signals let you declare a shared time-varying pattern once and have multiple tables track it with lag and correlation.

Defining Signals#

Signals pre-compute an array of values at init time. Define them in the signals section using either a duration range or an explicit length.

Duration-based (computes length automatically):

signal traffic(from: '2024-01-01T00:00:00Z', to: '2024-01-08T00:00:00Z', interval: '5m') {
  1000 + 400 * sin(2 * pi * i / 288)
}

Length-based (explicit number of data points):

signal promo_boost(length: 2016) {
  floor(50 * pow(cos(pi * mod(i, 500) / 500), 20))
}

The variable i is the index into the signal buffer (0 to length-1). All built-in math functions and globals are available in signal expressions.

Consuming Signals#

Three functions read signal values:

FunctionDescription
signal('name')Value at the current iter(), wrapping around
signal_at('name', index)Value at a specific index
signal_correlated('name', lag, correlation)Lagged value with correlated noise

Correlated Signals#

signal_correlated is the key function for multi-table patterns. It takes three arguments:

  • name - the signal to correlate with
  • lag - how far behind to look (integer for iterations, or a duration string like '2h' if the signal was defined with from/to/interval)
  • correlation - a value from -1 to 1 controlling how closely the output tracks the signal

The correlation math:

result = r × S[iter - lag] + (1 - |r|) × mean(S) + √(1 - r²) × stddev(S) × noise
CorrelationBehaviour
1.0Exact copy of the lagged signal
0.7Follows the shape with moderate noise
0.3Weak tracking, mostly noise around the mean
0.0Random noise centred on the signal mean
-1.0Inverted copy of the lagged signal

Example: Cascading Ecommerce Effects#

Simulates a week of correlated observability data in a 5-minute run. As traffic ramps up, orders follow with a 15-minute lag, support tickets follow with a 2-hour lag, and server errors spike after promotional events. Each table reads from the signal buffers using signal_at() with global_iter() as the index, and timestamps are computed SQL-side using modular arithmetic to wrap at the week boundary.

signal traffic(from: '2024-01-01T00:00:00Z', to: '2024-01-08T00:00:00Z', interval: '5m') {
  1000 + 400 * sin(2 * 3.14159265 * i / 288) + 200 * sin(2 * 3.14159265 * i / 2016)
}

signal promo_boost(length: 2016) {
  floor(50 * pow(cos(3.14159265 * mod(i, 500) / 500), 20))
}

weights {
  insert_page_views = 25
  insert_orders = 25
  insert_tickets = 25
  insert_errors = 25
}

run {
  insert_page_views(type: exec)
    `INSERT INTO page_views (ts, view_count, page)
     VALUES (
       '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
       $2::INT,
       $3
     )` (
      global_iter(),
      floor(signal_at('traffic', int(global_iter())) + signal_at('promo_boost', int(global_iter()))),
      set(['home','product','cart','checkout'], [40,30,20,10])
    )

  insert_orders(type: exec)
    `INSERT INTO orders (ts, order_count, amount)
     VALUES (
       '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
       $2::INT,
       $3::DECIMAL
     )` (
      global_iter(),
      floor(abs(signal_at('traffic', int(global_iter()) - 3) * 0.7 + norm(0, 300, -500, 500) * 0.3)),
      uniform.float(10, 500, 2)
    )

  insert_tickets(type: exec)
    `INSERT INTO support_tickets (ts, ticket_count, category)
     VALUES (
       '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
       $2::INT,
       $3
     )` (
      global_iter(),
      floor(abs(signal_at('traffic', int(global_iter()) - 24) * 0.3 + norm(0, 300, -500, 500) * 0.7)),
      set(['billing','shipping','refund','other'], [30,35,20,15])
    )

  insert_errors(type: exec)
    `INSERT INTO server_errors (ts, error_count, error_type)
     VALUES (
       '2024-01-01T00:00:00Z'::TIMESTAMPTZ + (mod($1, 2016) * INTERVAL '5 minutes'),
       $2::INT,
       $3
     )` (
      global_iter(),
      floor(abs(signal_at('promo_boost', int(global_iter()) - 6) * 0.5 + norm(0, 10, -25, 25) * 0.5)),
      set(['timeout','500','oom','connection_reset'], [35,30,20,15])
    )
}

The full config is at examples/correlated_signals/crdb.edg. Run with -w 1 -d 5m for a single-worker 5-minute simulation covering one week of data.

Verification queries

Daily traffic pattern - view counts should show a sine wave across 24-hour buckets:

SELECT
  extract(hour FROM ts) AS hour,
  round(avg(view_count)::NUMERIC, 0) AS avg_views,
  repeat('▒', (round(avg(view_count) / max(avg(view_count)) OVER () * 40))::INT) AS histogram
FROM page_views
GROUP BY hour
ORDER BY hour;
  hour | avg_views |                histogram
-------+-----------+-------------------------------------------
     0 |      1053 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     1 |      1159 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     2 |      1250 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     3 |      1321 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     4 |      1374 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     5 |      1408 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     6 |      1407 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     7 |      1381 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     8 |      1325 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     9 |      1255 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    10 |      1166 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    11 |      1064 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    12 |       959 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    13 |       861 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    14 |       768 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    15 |       692 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    16 |       642 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    17 |       612 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    18 |       614 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    19 |       641 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    20 |       691 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    21 |       759 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    22 |       854 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    23 |       951 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Order-traffic correlation - orders should loosely follow page views with a lag:

SELECT
  date_trunc('hour', v.ts) AS hour,
  repeat('▒', (round(avg(v.view_count) / max(avg(v.view_count)) OVER () * 20))::INT) AS views,
  repeat('▒', (round(avg(o.order_count) / max(avg(o.order_count)) OVER () * 20))::INT) AS orders
FROM page_views v
JOIN orders o ON date_trunc('hour', v.ts) = date_trunc('hour', o.ts)
GROUP BY date_trunc('hour', v.ts)
ORDER BY hour
LIMIT 24;
           hour          |       views        |       orders
-------------------------+--------------------+---------------------
  2024-01-01 00:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒     | ▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 01:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒    | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 02:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 03:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 04:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 05:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 06:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 07:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 08:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 09:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 10:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 11:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒     | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 12:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 13:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒       | ▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 14:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒        | ▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 15:00:00+00 | ▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 16:00:00+00 | ▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 17:00:00+00 | ▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒
  2024-01-01 18:00:00+00 | ▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒
  2024-01-01 19:00:00+00 | ▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 20:00:00+00 | ▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 21:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒        | ▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 22:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 23:00:00+00 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒     | ▒▒▒▒▒▒▒▒▒▒▒▒▒

Promo boost spikes - errors should spike shortly after promotional periods:

SELECT
  date_trunc('hour', ts) AS hour,
  sum(error_count) AS total_errors,
  repeat('▒', greatest(1, (round(sum(error_count)::FLOAT8 / max(sum(error_count)::FLOAT8) OVER () * 40))::INT)) AS histogram
FROM server_errors
GROUP BY hour
ORDER BY hour
LIMIT 24;
           hour          | total_errors |               histogram
-------------------------+--------------+-----------------------------------------
  2024-01-01 00:00:00+00 |        10656 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 01:00:00+00 |         9844 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 02:00:00+00 |         8972 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 03:00:00+00 |         6428 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 04:00:00+00 |         4179 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  2024-01-01 05:00:00+00 |         2635 | ▒▒▒▒▒▒▒▒▒▒
  2024-01-01 06:00:00+00 |         1961 | ▒▒▒▒▒▒▒
  2024-01-01 07:00:00+00 |         1534 | ▒▒▒▒▒▒
  2024-01-01 08:00:00+00 |         1540 | ▒▒▒▒▒▒
  2024-01-01 09:00:00+00 |         1428 | ▒▒▒▒▒
  2024-01-01 10:00:00+00 |         1652 | ▒▒▒▒▒▒
  2024-01-01 11:00:00+00 |         1553 | ▒▒▒▒▒▒
  2024-01-01 12:00:00+00 |         1706 | ▒▒▒▒▒▒
  2024-01-01 13:00:00+00 |         1490 | ▒▒▒▒▒
  2024-01-01 14:00:00+00 |         1522 | ▒▒▒▒▒
  2024-01-01 15:00:00+00 |         1534 | ▒▒▒▒▒▒
  2024-01-01 16:00:00+00 |         1581 | ▒▒▒▒▒▒
  2024-01-01 17:00:00+00 |         1500 | ▒▒▒▒▒
  2024-01-01 18:00:00+00 |         1628 | ▒▒▒▒▒▒
  2024-01-01 19:00:00+00 |         1635 | ▒▒▒▒▒▒
  2024-01-01 20:00:00+00 |         1724 | ▒▒▒▒▒▒
  2024-01-01 21:00:00+00 |         1770 | ▒▒▒▒▒▒
  2024-01-01 22:00:00+00 |         1369 | ▒▒▒▒▒
  2024-01-01 23:00:00+00 |         1530 | ▒▒▒▒▒▒

Ticket lag - support tickets should trail traffic peaks by ~2 hours:

SELECT
  extract(hour FROM v.ts)::INT AS hour,
  repeat('▒', (round(avg(v.view_count) / max(avg(v.view_count)) OVER () * 20))::INT) AS traffic,
  repeat('▒', greatest(1, (round(avg(t.ticket_count) / max(avg(t.ticket_count)) OVER () * 20))::INT)) AS tickets
FROM page_views v
JOIN support_tickets t ON date_trunc('hour', v.ts) = date_trunc('hour', t.ts)
GROUP BY extract(hour FROM v.ts)
ORDER BY hour
LIMIT 24;
  hour |       traffic        |       tickets
-------+----------------------+-----------------------
     0 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒▒▒▒▒▒▒▒▒▒▒▒
     1 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒     | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     2 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     3 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     4 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     5 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     6 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     7 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     8 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒  | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
     9 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    10 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒    | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    11 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    12 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒       | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    13 | ▒▒▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    14 | ▒▒▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
    15 | ▒▒▒▒▒▒▒▒▒▒           | ▒▒▒▒▒▒▒▒▒▒▒▒
    16 | ▒▒▒▒▒▒▒▒▒            | ▒▒▒▒▒▒▒▒▒▒▒
    17 | ▒▒▒▒▒▒▒▒▒            | ▒▒▒▒▒▒▒▒▒▒▒
    18 | ▒▒▒▒▒▒▒▒▒            | ▒▒▒▒▒▒▒▒▒▒
    19 | ▒▒▒▒▒▒▒▒▒            | ▒▒▒▒▒▒▒▒▒▒
    20 | ▒▒▒▒▒▒▒▒▒▒           | ▒▒▒▒▒▒▒▒▒▒
    21 | ▒▒▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒▒
    22 | ▒▒▒▒▒▒▒▒▒▒▒▒         | ▒▒▒▒▒▒▒▒▒▒▒
    23 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒       | ▒▒▒▒▒▒▒▒▒▒▒

Tuning

  • Signal length should match or exceed the number of iterations in your run. Values wrap around when global_iter() exceeds the signal length, repeating the pattern.
  • Lag is in iterations. If your signal has 5-minute intervals: 15 min = 3 iterations, 2 hours = 24 iterations.
  • Correlation is controlled by the * corr and * (1 - corr) weights on the signal and noise terms. Use norm() for Gaussian noise scaled to the signal’s range.
  • Wrap correlated outputs in floor(abs(...)) to avoid negative or fractional counts.
  • int(global_iter()) is required when passing to signal_at() because global_iter() returns int64 and signal_at() expects int.

Seasonal Timestamp Distributions PRO#

The patterns above shape a value as a run progresses. Seasons shape a timestamp: they answer “when did this row happen?” with a distribution that has calendar structure, rather than the flat spread that gen('date_range:...') and friends produce.

A season divides its range into buckets, weights each bucket, and draws in proportion to those weights. Every term is a multiplier, so independent shapes compose: month of year, day of week, hour of day, decay towards the present, and one-off spikes all multiply into a single curve.

Defining a Season#

season retail(from: '2023-01-01T00:00:00Z', to: '2025-01-01T00:00:00Z', bucket: '1h') {
  months   = [0.8, 0.7, 0.9, 0.9, 1, 0.9, 0.9, 1, 1, 1.2, 1.8, 2.5]
  weekdays = [1.1, 1, 1, 1, 1, 1.2, 1.3]
  hours    = [0.2, 0.1, 0.1, 0.1, 0.1, 0.2, 0.4, 0.7, 1, 1.2, 1.3, 1.3, 1.2, 1.2, 1.3, 1.3, 1.4, 1.6, 1.8, 1.7, 1.4, 1, 0.6, 0.3]
  recency  = { half_life: '8760h' }

  spikes = [
    { at: '2023-11-24T00:00:00Z', width: '96h', mag: 12 },
    { at: '2024-11-29T00:00:00Z', width: '96h', mag: 12 }
  ]

  weight = month == 12 && day > 25 ? 0.3 : 1
}

That reads as: quiet in February, busiest in December, weekends busier than midweek, an evening peak around 18:00, twice as much data in 2024 as in 2023, a Black Friday surge in each year, and a lull between Boxing Day and New Year.

TermShapeDescription
months12 weightsMonth of year, index 0 is January
weekdays7 weightsDay of week, index 0 is Sunday
hours24 weightsHour of day, index 0 is midnight
business_hours{ from, to, off }Hours in [from, to) weigh 1, all others weigh off
recency{ half_life }Exponential decay towards the past, halving every half_life
spikesarray of { at, width, mag }Peaks at mag on at, tapering linearly to 1 over width either side
weightexpressionArbitrary weight evaluated once per bucket

Consuming a Season#

FunctionDescription
season('name')An RFC3339 timestamp drawn from the distribution
season_weight('name', ts)The intensity at ts, normalised so the mean across the range is 1.0

season_weight is what makes a season more than a timestamp generator. Because its mean is 1.0, it multiplies straight into a count or an amount, so busy periods get bigger baskets as well as more of them:

seed {
  orders(type: exec_batch, count: 20000, size: 1000)
    `INSERT INTO orders (created_at, item_count, intensity) __values__` (
      created_at: season('retail'),
      item_count: int(1 + norm.float(2, 1, 0, 7, 0) * sqrt(season_weight('retail', arg('created_at')))),
      intensity: season_weight('retail', arg('created_at'))
    )
}

arg('created_at') reads the timestamp the same row already drew, so the weight matches the row rather than being an independent draw. Average baskets come out at 8.25 items in November against 2.02 in February, off the same expression.

Watch the scale of the weight before multiplying by it. A season with a mag: 12 spike on top of month, weekday and hour terms peaks near 47, and multiplying straight through gives 30-item shopping baskets. sqrt damps it to something plausible while keeping the shape; writing the raw weight to its own column makes the correlation easy to check in SQL.

business_hours and Weekends#

business_hours shapes the day only, and is deliberately blind to the day of week:

season support(from: '2024-01-01T00:00:00Z', to: '2025-01-01T00:00:00Z') {
  business_hours = { from: 8, to: 18, off: 0.05 }
  weekdays       = [0.2, 1, 1, 1, 1, 1, 0.3]
}

off is the multiplier applied to every hour outside the window, so off: 0.05 makes 03:00 20x less likely than a business hour. An off of 0 shuts out-of-hours traffic off completely.

Express quiet weekends with weekdays, not with business_hours. If business_hours also suppressed weekends, it would fight any weekend weighting you applied and the two would cancel out.

Choosing a Bucket#

bucket is the finest shape a season can express. Terms are validated against it, so an hours term with a 24h bucket is rejected rather than silently ignored:

season "retail": bucket "24h" is too coarse for hours, which needs "1h" or finer

Leave bucket off and edg picks the coarsest width that still resolves every declared term: 24h for months and weekdays, 1h once hours or business_hours appear, and narrower still for a spike whose width is under an hour. The cumulative-weight table is capped at 1,048,576 buckets; a bucket too fine for the range is widened to fit and a warning is logged.

Only the declarative terms drive that choice. A weight expression is opaque to edg, so a season shaped by weight alone gets the 24h default however fine its expression is; one that reads hour needs bucket: '1h' spelling out. See Shifting the Shape of the Day for what happens if you forget.

A Season Cookbook#

Every shape below is a complete season, and each is runnable in examples/seasonal_cookbook. The counts under each one are measured, from staging 20,000 rows of that season with edg stage --rng-seed 1.

One Term at a Time#

Quiet weekends - Index 0 is Sunday:

season weekly(from: '2024-01-01T00:00:00Z', to: '2024-04-01T00:00:00Z') {
  weekdays = [0.3, 1, 1, 1, 1, 1, 0.3]
}

Weekdays land ~3,550 rows each, Saturday and Sunday ~1,100. The weekend takes 11% of the data against the 28.6% a flat spread would give.

day | count | histogram
----+-------+-------------------------------
Sun |  1067 | ▒▒▒▒▒▒▒▒▒
Mon |  3509 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Tue |  3645 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Wed |  3492 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Thu |  3554 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Fri |  3605 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Sat |  1128 | ▒▒▒▒▒▒▒▒▒

A working day - 24 weights, index 0 is midnight:

season daily(from: '2024-01-01T00:00:00Z', to: '2024-02-01T00:00:00Z') {
  hours = [0.2, 0.1, 0.1, 0.1, 0.1, 0.2, 0.5, 1, 1.5, 1.5, 1.3, 1.2, 1.4, 1.3, 1.2, 1.2, 1.4, 1.8, 2, 1.8, 1.4, 1, 0.6, 0.3]
}

18:00 peaks at 1,761 rows, 01:00 troughs at 80: a 22:1 spread off weights that only differ 20:1, because the hour term is the only shape in play.

hour | count | histogram
-----+-------+-------------------------------
  00 |   178 | ▒▒▒
  01 |    80 | ▒
  02 |    83 | ▒
  03 |    90 | ▒▒
  04 |    86 | ▒
  05 |   178 | ▒▒▒
  06 |   437 | ▒▒▒▒▒▒▒
  07 |   847 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  08 |  1276 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  09 |  1351 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  10 |  1119 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  11 |  1016 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  12 |  1207 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  13 |  1104 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  14 |  1050 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  15 |   993 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  16 |  1200 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  17 |  1580 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  18 |  1761 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  19 |  1598 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  20 |  1185 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  21 |   841 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  22 |   497 | ▒▒▒▒▒▒▒▒
  23 |   243 | ▒▒▒▒

A summer business - 12 weights, index 0 is January:

season annual(from: '2024-01-01T00:00:00Z', to: '2025-01-01T00:00:00Z') {
  months = [0.6, 0.6, 0.8, 1, 1.2, 1.5, 1.8, 1.8, 1.2, 1, 0.9, 0.7]
}

August takes 2,816 rows and February 875. Month lengths ride on top of the weights, so January and February share a weight of 0.6 but not a count: 914 against 875, the 31/29 ratio of their lengths.

month | count | histogram
------+-------+-------------------------------
  Jan |   914 | ▒▒▒▒▒▒▒▒▒▒
  Feb |   875 | ▒▒▒▒▒▒▒▒▒
  Mar |  1251 | ▒▒▒▒▒▒▒▒▒▒▒▒▒
  Apr |  1553 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  May |  1830 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Jun |  2241 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Jul |  2730 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Aug |  2816 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Sep |  1826 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Oct |  1577 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Nov |  1341 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Dec |  1046 | ▒▒▒▒▒▒▒▒▒▒▒

Office hours - Cheaper to write than the equivalent 24-element hours array, and easier to read:

season office(from: '2024-01-01T00:00:00Z', to: '2024-02-01T00:00:00Z') {
  business_hours = { from: 9, to: 17, off: 0.05 }
}

91% of rows fall in [9, 17). Each in-hours hour holds ~2,300 rows against ~117 for each hour outside, matching the 20:1 that off: 0.05 asks for.

hour | count | histogram
-----+-------+-------------------------------
  00 |   118 | ▒▒
  01 |   117 | ▒
  02 |   121 | ▒▒
  03 |   110 | ▒
  04 |   125 | ▒▒
  05 |    88 | ▒
  06 |   124 | ▒▒
  07 |   110 | ▒
  08 |   107 | ▒
  09 |  2270 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  10 |  2332 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  11 |  2246 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  12 |  2221 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  13 |  2282 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  14 |  2195 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  15 |  2352 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  16 |  2255 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  17 |   125 | ▒▒
  18 |   110 | ▒
  19 |   107 | ▒
  20 |   128 | ▒▒
  21 |   131 | ▒▒
  22 |   113 | ▒
  23 |   113 | ▒

A growing product - No calendar shape at all, just more data as time moves towards to:

season growth(from: '2023-01-01T00:00:00Z', to: '2025-01-01T00:00:00Z') {
  recency = { half_life: '4380h' }
}

A six-month half-life over two years gives 2024 four times the rows of 2023 (16,042 against 3,958), and December 2024 alone (2,318) four times the whole of the first quarter of 2023 (577). Each quarter is ~1.41x the one before it, which is the half-life compounding:

quarter | count | histogram
--------+-------+-------------------------------
2023 Q1 |   577 | ▒▒▒
2023 Q2 |   766 | ▒▒▒▒
2023 Q3 |  1052 | ▒▒▒▒▒
2023 Q4 |  1563 | ▒▒▒▒▒▒▒
2024 Q1 |  2172 | ▒▒▒▒▒▒▒▒▒▒
2024 Q2 |  3144 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
2024 Q3 |  4429 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
2024 Q4 |  6297 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

A launch - One spike, no baseline shape:

season launch(from: '2024-01-01T00:00:00Z', to: '2024-07-01T00:00:00Z') {
  spikes = [{ at: '2024-03-15T00:00:00Z', width: '168h', mag: 25 }]
}

The launch week takes 37% of six months of data, and March as a whole 56%. width is the half-width: the weight climbs linearly from 1 at at - width to mag at at, and back down again, so this spike is a fortnight wide in total. Daily counts trace that triangle exactly, sitting flat at ~57 outside it:

  date | count | histogram
-------+-------+-------------------------------
07 Mar |    57 | ▒
08 Mar |    57 | ▒
09 Mar |   265 | ▒▒▒▒▒▒
10 Mar |   458 | ▒▒▒▒▒▒▒▒▒▒
11 Mar |   664 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
12 Mar |   863 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
13 Mar |  1027 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
14 Mar |  1201 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
15 Mar |  1401 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
16 Mar |  1167 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
17 Mar |   973 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
18 Mar |   864 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
19 Mar |   608 | ▒▒▒▒▒▒▒▒▒▒▒▒▒
20 Mar |   452 | ▒▒▒▒▒▒▒▒▒▒
21 Mar |   246 | ▒▒▒▒▒
22 Mar |    57 | ▒
23 Mar |    59 | ▒

Composing Terms#

Terms multiply. Each one is a separate, independent shape, and a bucket’s weight is their product.

A nightly batch window - Hours and weekdays together, so the overnight peak also thins out at the weekend:

season etl(from: '2024-01-01T00:00:00Z', to: '2024-02-01T00:00:00Z', bucket: '1h') {
  hours    = [3, 4, 2, 0.5, 0.2, 0.2, 0.2, 0.3, 0.5, 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.6, 0.5, 0.4, 0.4, 0.5, 0.8, 1.5, 2.5]
  weekdays = [0.4, 1, 1, 1, 1, 1, 0.4]
}

51% of rows land in the four hours from 23:00 to 03:00, and only 12% at the weekend.

hour | count | histogram
-----+-------+-------------------------------
  00 |  2691 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  01 |  3495 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  02 |  1732 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  03 |   419 | ▒▒▒▒
  04 |   164 | ▒
  05 |   201 | ▒▒
  06 |   199 | ▒▒
  07 |   268 | ▒▒
  08 |   451 | ▒▒▒▒
  09 |   565 | ▒▒▒▒▒
  10 |   578 | ▒▒▒▒▒
  11 |   509 | ▒▒▒▒
  12 |   535 | ▒▒▒▒▒
  13 |   531 | ▒▒▒▒▒
  14 |   563 | ▒▒▒▒▒
  15 |   573 | ▒▒▒▒▒
  16 |   533 | ▒▒▒▒▒
  17 |   464 | ▒▒▒▒
  18 |   359 | ▒▒▒
  19 |   362 | ▒▒▒
  20 |   461 | ▒▒▒▒
  21 |   693 | ▒▒▒▒▒▒
  22 |  1382 | ▒▒▒▒▒▒▒▒▒▒▒▒
  23 |  2272 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

A growing SaaS app - Three independent shapes, one for each timescale that matters:

season saas(from: '2023-06-01T00:00:00Z', to: '2025-01-01T00:00:00Z', bucket: '1h') {
  recency        = { half_life: '4380h' }
  weekdays       = [0.2, 1, 1.1, 1.1, 1, 0.8, 0.2]
  business_hours = { from: 8, to: 19, off: 0.1 }
}

Rows-per-half-year run 284, 2,866, 5,608, 11,242 as the product grows; 83% of rows are in weekday office hours and 7% at the weekend. Note that business_hours is blind to the day of week on purpose, so it composes with weekdays rather than fighting it. Each term keeps its own shape in the output, so it’s worth looking at one axis at a time:

quarter | count | histogram
--------+-------+-------------------------------
2023 Q2 |   284 | ▒
2023 Q3 |  1208 | ▒▒▒▒▒
2023 Q4 |  1658 | ▒▒▒▒▒▒▒
2024 Q1 |  2323 | ▒▒▒▒▒▒▒▒▒▒▒
2024 Q2 |  3285 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
2024 Q3 |  4608 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
2024 Q4 |  6634 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

day | count | histogram
----+-------+-------------------------------
Sun |   752 | ▒▒▒▒▒
Mon |  3704 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Tue |  4179 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Wed |  4013 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Thu |  3667 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Fri |  2981 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Sat |   704 | ▒▒▒▒▒

A holiday shutdown - A spike with mag below 1 is a lull, which reads better than the equivalent weight expression and needs no calendar arithmetic:

season shutdown(from: '2024-01-01T00:00:00Z', to: '2025-01-01T00:00:00Z') {
  weekdays = [0.3, 1, 1, 1, 1, 1, 0.3]

  spikes = [
    { at: '2024-08-10T00:00:00Z', width: '336h', mag: 0.2 },
    { at: '2024-12-25T00:00:00Z', width: '240h', mag: 0.1 }
  ]
}

The fortnight from 3 August takes 329 rows against 890 for the same window in July, and the Christmas fortnight drops December to 1,290 against a ~1,750 monthly baseline. Only the two months holding a lull sit below that baseline:

month | count | histogram
------+-------+-------------------------------
  Jan |  1850 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Feb |  1753 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Mar |  1619 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Apr |  1757 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  May |  1866 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Jun |  1651 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Jul |  1742 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Aug |  1194 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Sep |  1710 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Oct |  1809 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Nov |  1759 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Dec |  1290 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Month-end billing - Some shapes have no term, and that’s what weight is for. day, month, year, yearday, weekday, hour and the bucket start t are all in scope:

season billing(from: '2024-01-01T00:00:00Z', to: '2025-01-01T00:00:00Z') {
  weight = day >= 28 || day <= 2 ? 6 : 1
}

Six days of each month carry 56% of the year’s rows: ~2,000 on each of the 1st, 2nd, 28th, 29th and 30th against ~350 mid-month. The 31st gets 1,197 for the obvious reason that only seven months have one, and the 30th 1,834 because February hasn’t got one either:

day | count | histogram
----+-------+-------------------------------
 01 |  2119 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 02 |  2010 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 03 |   348 | ▒▒▒▒▒
 04 |   360 | ▒▒▒▒▒
 05 |   356 | ▒▒▒▒▒
 06 |   323 | ▒▒▒▒▒
 07 |   340 | ▒▒▒▒▒
 08 |   333 | ▒▒▒▒▒
 09 |   346 | ▒▒▒▒▒
 10 |   352 | ▒▒▒▒▒
 11 |   328 | ▒▒▒▒▒
 12 |   346 | ▒▒▒▒▒
 13 |   361 | ▒▒▒▒▒
 14 |   352 | ▒▒▒▒▒
 15 |   352 | ▒▒▒▒▒
 16 |   319 | ▒▒▒▒▒
 17 |   363 | ▒▒▒▒▒
 18 |   371 | ▒▒▒▒▒
 19 |   376 | ▒▒▒▒▒
 20 |   338 | ▒▒▒▒▒
 21 |   347 | ▒▒▒▒▒
 22 |   353 | ▒▒▒▒▒
 23 |   370 | ▒▒▒▒▒
 24 |   365 | ▒▒▒▒▒
 25 |   346 | ▒▒▒▒▒
 26 |   355 | ▒▒▒▒▒
 27 |   342 | ▒▒▒▒▒
 28 |  2071 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 29 |  2027 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 30 |  1834 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 31 |  1197 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Interactions and Multi-Level Shifts#

Shifting the Shape of the Day#

Array terms are separable: months and hours each shape their own axis, and multiplying them can only ever scale a fixed daily curve up and down month by month. A daily curve whose shape changes with the month is an interaction, and interactions live in weight:

season summer_evenings(from: '2024-01-01T00:00:00Z', to: '2025-01-01T00:00:00Z', bucket: '1h') {
  weight = 1 + 0.9 * cos(2 * pi * (hour - (17 + 4 * sin(2 * pi * (yearday - 105) / 365))) / 24)
}

The inner sin walks the peak hour between 13:00 and 21:00 over the year; the outer cos is the daily curve around whatever peak that day has. The mean hour of day comes out at 13.0 in January, 17.1 in April, 20.9 in July and 16.9 in October: the evening rush drifting later through summer and back again. Two months side by side, each hour scaled against the busiest hour of the pair, show the whole curve sliding rather than growing:

hour | January            | July
-----+--------------------+--------------------
  00 | ▒                  | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  01 | ▒▒                 | ▒▒▒▒▒▒▒▒▒▒▒▒
  02 | ▒▒                 | ▒▒▒▒▒▒▒▒▒
  03 | ▒▒                 | ▒▒▒▒▒▒▒▒▒
  04 | ▒▒▒                | ▒▒▒▒▒▒▒
  05 | ▒▒▒▒▒              | ▒▒▒▒▒
  06 | ▒▒▒▒               | ▒▒▒
  07 | ▒▒▒▒▒▒▒▒▒          | ▒▒
  08 | ▒▒▒▒▒▒▒▒▒▒▒▒       | ▒
  09 | ▒▒▒▒▒▒▒▒▒▒▒▒       | ▒
  10 | ▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒
  11 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒    | ▒▒
  12 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ | ▒▒▒
  13 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒
  14 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒
  15 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒    | ▒▒▒▒▒▒▒▒▒
  16 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒   | ▒▒▒▒▒▒▒▒▒▒▒▒
  17 | ▒▒▒▒▒▒▒▒▒▒▒▒▒      | ▒▒▒▒▒▒▒▒▒▒▒▒▒
  18 | ▒▒▒▒▒▒▒▒▒▒▒        | ▒▒▒▒▒▒▒▒▒▒▒▒▒
  19 | ▒▒▒▒▒▒▒▒▒          | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  20 | ▒▒▒▒▒▒             | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  21 | ▒▒▒▒               | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  22 | ▒▒▒                | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  23 | ▒▒                 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

No pair of months and hours arrays can produce that, because their product would give July the same curve as January with every bar scaled by one number.

The explicit bucket: '1h' is doing real work here. Drop it and the same season is built at the 24h default, sampling the expression only at midnight each day, where hour is always 0. The daily shape doesn’t just disappear, it aliases into a yearly one: hours flatten out (790 to 880 rows each, i.e. noise), while July collects 3,252 rows against January’s 282, because the midsummer peak of 21:00 sits close to the midnight the expression is sampled at, and the midwinter peak of 13:00 sits as far from it as an hour can. What was meant to be a daily curve comes back as an annual one:

month | count | histogram
------+-------+-------------------------------
  Jan |   282 | ▒▒▒
  Feb |   314 | ▒▒▒
  Mar |   812 | ▒▒▒▒▒▒▒
  Apr |  1608 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  May |  2618 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Jun |  2980 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Jul |  3252 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Aug |  3134 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Sep |  2424 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Oct |  1486 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
  Nov |   726 | ▒▒▒▒▒▒▒
  Dec |   364 | ▒▒▒

Nothing errors, so check the shape you asked for actually landed.

A Multi-Level Platform#

Five terms of calendar structure plus a step change in traffic:

season platform(from: '2023-01-01T00:00:00Z', to: '2025-01-01T00:00:00Z', bucket: '1h') {
  months   = [0.9, 0.9, 1.3, 0.9, 0.9, 1.3, 0.9, 0.8, 1.3, 1, 1, 1.4]
  weekdays = [0.3, 1.1, 1.2, 1.2, 1.1, 0.9, 0.3]
  hours    = [0.3, 0.2, 0.2, 0.2, 0.3, 0.4, 0.6, 1, 1.4, 1.6, 1.6, 1.4, 1.2, 1.4, 1.6, 1.6, 1.4, 1.1, 0.8, 0.6, 0.5, 0.5, 0.4, 0.3]
  recency  = { half_life: '8760h' }

  spikes = [
    { at: '2024-05-14T00:00:00Z', width: '72h', mag: 8 },
    { at: '2024-09-03T12:00:00Z', width: '6h', mag: 0.05 }
  ]

  weight = (year > 2024 || (year == 2024 && month >= 3) ? 2 : 1) * (day >= 28 ? 1.8 : 1)
}

Each level shows up independently in the output:

LevelTermMeasured
Year-on-year growthrecency2023: 4,149, 2024: 15,851
Quarter-end reportingmonthsMeasured on 2023, before the step change: June 410 vs May 283, September 444 vs August 315, December 613 vs November 419
Working weekweekdaysTuesday 4,150, Sunday 1,009
Working dayhours09:00 peaks at 1,613, 01:00 troughs at 188
Release-day surgespikes[0]792 rows over 13-15 May against a 185-row May norm for three days
Six-hour outagespikes[1], mag: 0.0511 rows in the window against 23 the day before
Migration step changeweight14.0 rows/day in Jan-Feb 2024, 35.3 in Mar-Apr
Month-end batchweight17.4% of rows on days 28-31, against the 11.4% those days are of the range

Several of those levels are visible in a single monthly histogram: the recency curve carrying both years upwards, the quarter-end months standing above their neighbours (clearest in 2023, before the other levels get loud), the March 2024 cliff, and the May 2024 release spike:

 month | count | histogram
-------+-------+-------------------------------
Jan 23 |   213 | ▒▒▒
Feb 23 |   183 | ▒▒
Mar 23 |   360 | ▒▒▒▒
Apr 23 |   217 | ▒▒▒
May 23 |   283 | ▒▒▒
Jun 23 |   410 | ▒▒▒▒▒
Jul 23 |   250 | ▒▒▒
Aug 23 |   315 | ▒▒▒▒
Sep 23 |   444 | ▒▒▒▒▒
Oct 23 |   442 | ▒▒▒▒▒
Nov 23 |   419 | ▒▒▒▒▒
Dec 23 |   613 | ▒▒▒▒▒▒▒
Jan 24 |   440 | ▒▒▒▒▒
Feb 24 |   398 | ▒▒▒▒▒
Mar 24 |  1235 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Apr 24 |   919 | ▒▒▒▒▒▒▒▒▒▒▒
May 24 |  1913 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Jun 24 |  1375 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Jul 24 |  1254 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Aug 24 |  1057 | ▒▒▒▒▒▒▒▒▒▒▒▒▒
Sep 24 |  1629 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Oct 24 |  1616 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Nov 24 |  1554 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
Dec 24 |  2461 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

Two things are worth pulling out. First, the step change lives in weight rather than in recency, because a regime shift is a cliff and recency is a smooth curve; they compose, so growth continues either side of the cliff. Second, weight holds two unrelated rules multiplied together, which is the same composition the built-in terms use, and it’s usually clearer than folding both into one condition.

Peak weight here is 20.5 against a mean of 1.0, so damp it with sqrt before multiplying it into a count.

Follow the Sun#

A season draws from one distribution, so a workload spanning regions needs one season per region and a per-row choice between them:

season eu(from: '2024-01-01T00:00:00Z', to: '2024-02-01T00:00:00Z', bucket: '1h') {
  business_hours = { from: 7, to: 16, off: 0.05 }
}

season us(from: '2024-01-01T00:00:00Z', to: '2024-02-01T00:00:00Z', bucket: '1h') {
  business_hours = { from: 13, to: 22, off: 0.05 }
}

# business_hours can't wrap midnight, so an APAC day expressed in UTC
# needs the hours array.
season apac(from: '2024-01-01T00:00:00Z', to: '2024-02-01T00:00:00Z', bucket: '1h') {
  hours = [1, 1, 1, 1, 1, 1, 1, 1, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 1]
}

seed {
  populate_events(type: exec_batch, count: 20000, size: 1000)
    `INSERT INTO events (region, ts) __values__` (
      region: set(['eu', 'us', 'apac'], [50, 35, 15]),
      ts: arg('region') == 'eu' ? season('eu') : arg('region') == 'us' ? season('us') : season('apac')
    )
}

Each region keeps at least 92% of its rows inside its own working day, and because the ternary only evaluates the branch it takes, each row draws from exactly one season. Summed across regions the table has no quiet night at all, only a 22:00 UTC trough of 115 rows where APAC has finished and the Americas have not yet started, against ~1,800 an hour across the 13:00-16:00 EU/US overlap:

utc |    eu |    us |  apac | total | histogram
----+-------+-------+-------+-------+---------------------------
 00 |    41 |    35 |   337 |   413 | ▒▒▒▒▒▒
 01 |    54 |    37 |   308 |   399 | ▒▒▒▒▒▒
 02 |    40 |    33 |   282 |   355 | ▒▒▒▒▒
 03 |    50 |    45 |   280 |   375 | ▒▒▒▒▒
 04 |    53 |    46 |   302 |   401 | ▒▒▒▒▒▒
 05 |    45 |    38 |   340 |   423 | ▒▒▒▒▒▒
 06 |    59 |    28 |   280 |   367 | ▒▒▒▒▒
 07 |  1003 |    38 |   308 |  1349 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 08 |  1000 |    38 |    12 |  1050 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 09 |   962 |    32 |    14 |  1008 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 10 |  1030 |    45 |    13 |  1088 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 11 |  1068 |    42 |    17 |  1127 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 12 |   953 |    43 |    13 |  1009 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 13 |   998 |   749 |    14 |  1761 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 14 |  1089 |   715 |    15 |  1819 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 15 |  1081 |   715 |    17 |  1813 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
 16 |    54 |   678 |    14 |   746 | ▒▒▒▒▒▒▒▒▒▒▒
 17 |    51 |   758 |    16 |   825 | ▒▒▒▒▒▒▒▒▒▒▒▒
 18 |    62 |   742 |    12 |   816 | ▒▒▒▒▒▒▒▒▒▒▒▒
 19 |    51 |   714 |    20 |   785 | ▒▒▒▒▒▒▒▒▒▒▒
 20 |    50 |   709 |     7 |   766 | ▒▒▒▒▒▒▒▒▒▒▒
 21 |    58 |   738 |    16 |   812 | ▒▒▒▒▒▒▒▒▒▒▒▒
 22 |    62 |    39 |    14 |   115 | ▒▒
 23 |    48 |    35 |   295 |   378 | ▒▒▒▒▒

The 15:00-16:00 step is the EU day ending, and the 07:00 double-count is the hour where the EU has started and APAC hasn’t finished.

Weight the set to weight the regions: the split above is the mix of rows, and each season decides only when its own rows land.

Verification#

Stage the data to a file and count it, or query it back:

SELECT
  date_trunc('month', created_at) AS month,
  count(*),
  repeat('▒', (count(*) / 100)::INT) AS histogram
FROM orders
GROUP BY 1
ORDER BY 1

The 2024 slice of 20,000 rows drawn from the retail season above looks like this (2023 takes the other third, thanks to recency):

month | count | histogram
------+-------+---------------------------------
    1 |   433 | ▒▒▒▒
    2 |   374 | ▒▒▒
    3 |   560 | ▒▒▒▒▒
    4 |   543 | ▒▒▒▒▒
    5 |   695 | ▒▒▒▒▒▒
    6 |   634 | ▒▒▒▒▒▒
    7 |   707 | ▒▒▒▒▒▒▒
    8 |   804 | ▒▒▒▒▒▒▒▒
    9 |   829 | ▒▒▒▒▒▒▒▒
   10 |  1084 | ▒▒▒▒▒▒▒▒▒▒
   11 |  4104 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
   12 |  2643 | ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒

November dominates because the month weight and the Black Friday spike multiply. Only 6.3% of December rows land after the 25th, against the 19.4% a flat month would give, which is the weight expression doing its job.

Tuning

  • Weights are relative. Only their ratios matter, so [1, 2] and [50, 100] are the same season.
  • A mag below 1 turns a spike into a lull, which is a cleaner way to model a holiday shutdown than a weight expression.
  • recency is anchored at to, so a half_life of 8760h means data one year before the end of the range is half as dense as data at the end.
  • A season is not a signal. Seasons place rows in time and have no notion of iteration; signals produce a value per iteration and have no notion of the calendar. Use both together when a run needs a realistic timestamp and a correlated per-iteration value.

The full config is at examples/seasonal/crdb.edg, with SQL to verify each shape. Every season in the cookbook above is runnable as examples/seasonal_cookbook/crdb.edg.