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:
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.
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 |