Skip to content

Planning Date System

Overview

ForecastAI uses a planning date system that allows the pipeline to operate as if "today" were a different date than the real calendar date. This is critical for backtesting, replay, multi-timezone deployments, and demo environments.

Every piece of business logic that depends on the current date must use planning_today() / planning_monday() instead of date.today() or datetime.now().


Why Planning Date Matters

Without a planning date override:

  • A replay engine re-running last week's pipeline would write data into this week's partition, corrupting the live view
  • A demo environment would see its data shift every calendar week, breaking walkthroughs
  • A team in CET and a team in IST could disagree on which week "this week" is

The planning date table gives operators a single knob to freeze the effective "today" for any pipeline (or globally).


Resolution Chain

planning_today(pipeline_id) resolves in this priority order:

Priority Source When it applies
1 (highest) REPLAY_PLANNING_DATE env var Replay engine — always wins, process-level override
2 scenario.planning_date row for the specific pipeline_id Per-pipeline virtual date
3 scenario.planning_date row for pipeline_id = -1 Global default for all pipelines
4 (lowest) datetime.now(timezone.utc).date() Real calendar date in UTC — final fallback

If none of the first three sources are set, the system behaves identically to using the real calendar date (in UTC).


API

planning_today(pipeline_id=None) -> date

Returns the effective planning date. Always pass pipeline_id when it is available — this allows per-pipeline date overrides to take effect.

from utils.planning_date import planning_today

today = planning_today(pipeline_id)   # correct — respects per-pipeline override
today = planning_today()              # fallback — uses global default or UTC date

planning_monday(pipeline_id=None) -> date

Returns Monday of the planning week. This is the canonical archive_date for all ClickHouse PIPE_* row inserts. Equivalent to planning_today(pipeline_id) - timedelta(days=planning_today(pipeline_id).weekday()).

from utils.planning_date import planning_monday

archive_date = planning_monday(pipeline_id)  # correct — Monday of planning week

The planning_date Table

Schema

CREATE TABLE scenario.planning_date (
    pipeline_id   INTEGER PRIMARY KEY,
    planning_date DATE     NOT NULL,
    updated_at    TIMESTAMPTZ DEFAULT NOW()
);

Setting a planning date

pipeline_id Effect
A positive integer (e.g. 4) Override "today" for pipeline 4 only
-1 Global default — applies to all pipelines that don't have their own row

API Endpoints

Method Path Description
GET /api/planning-date?pipeline_id={id} Get the effective planning date for a pipeline
GET /api/planning-dates List all planning_date rows
PUT /api/planning-date Set or update a planning date
DELETE /api/planning-date/{pipeline_id} Remove a planning date override

Example: Set a global planning date

PUT /api/planning-date
Authorization: Bearer <token>
Content-Type: application/json

{
    "pipeline_id": -1,
    "planning_date": "2026-06-01"
}

All pipelines without a pipeline-specific override will now see "today" as 2026-06-01.


Business Logic vs. Non-Business Logic

Must use planning_today() / planning_monday()

Use case Why
Computing archive_date for CH PIPE_* inserts Partition routing depends on the planning week
Forecast horizon start dates Forecasts must anchor to the planning "today"
Allocation / supply run anchoring Order timing and backlog aging depend on "today"
Freeze / snapshot dating compute_archive_date() uses planning_today() internally
MEIO service-level time-slice anchoring Safety stock bounds depend on the planning horizon
Any date that affects data correctness If it changes what data is visible or where it's stored, it's business logic

OK to use date.today() / datetime.now()

Use case Why
Log message timestamps Operational, not data-affecting
Performance timing (time.time(), Instant::now() in Rust) Measurement, not business logic
Cache TTL expiry Operational housekeeping
File naming with calendar date Not data-affecting

ClickHouse DEFAULT — Safety Net Only

All PIPE_* table DDLs include:

archive_date  Date  DEFAULT toMonday(today())

This is a last-resort safety net only. ClickHouse cannot query the PostgreSQL planning_date table, so the DEFAULT always uses the real calendar date.

If a row is ever inserted without an explicit archive_date and relies on the CH DEFAULT, the planning_date table is bypassed — this is a bug. All Python inserts must explicitly pass archive_date computed via planning_monday().


Implementation

Key files

File Role
files/utils/planning_date.py Canonical planning_today() and planning_monday() implementations
files/historization/freeze.py compute_archive_date() uses planning_today(pipeline_id) internally
files/db/db.py drop_ch_partition() auto-pads missing archive_date via planning_monday(pipeline_id)
files/DDL/ch_schema.sql CH DDL with safety-net DEFAULT toMonday(today())
files/replay/replay_engine.py Sets REPLAY_PLANNING_DATE env var per iteration

Integration with Replay Engine

The replay engine sets REPLAY_PLANNING_DATE as a process-level environment variable before spawning supply and allocation subprocesses. This is the highest-priority override and always wins, ensuring that all code paths — including third-party libraries that might call planning_today() — see the correct iteration date.

# replay_engine.py
os.environ["REPLAY_PLANNING_DATE"] = planning_date.isoformat()

The env var is cleared after each iteration so that subsequent code returns to the DB-based resolution.

Integration with Freeze

compute_archive_date() in historization/freeze.py uses planning_today(pipeline_id) to determine the freeze target. This ensures that freezing respects the per-pipeline planning date, so a demo pipeline frozen at a virtual date gets the correct archive_date.


Common Mistakes

Mistake Consequence Fix
Using date.today() for archive_date Data lands in the wrong CH partition when planning_date is set Use planning_monday(pipeline_id)
Calling planning_monday() without pipeline_id Per-pipeline overrides are ignored Pass pipeline_id whenever it's in scope
Calling planning_today() in a loop Opens a DB connection per iteration Compute once, store in a local variable
Relying on CH DEFAULT toMonday(today()) Bypasses the planning_date table entirely Always pass archive_date explicitly from Python