Developer Guide
How PowerForecast is built, how to run it locally, how to test it and where to change things.
Architecture
forecast-wizard (static) PowerAdmin PowerAI
index.html shell: sidebar, the run ─────────► template (steps, ──► per-step AI assist,
wizard.html 4 steps (Alpine) fields, options) pre-flight review, critic
results.html one Results page
│ POST /jobs → poll /jobs/{id} → /result + /diagnostics
▼
forecast-api (FastAPI, port 8020)
Profiler → Validator → Selector → Engine (cross-validation champion) → Diagnostics
│
▼
Redis job store (spec / result / diagnostics, with a time-to-live; PowerOrchestrator fallback)
StatsForecast: AutoARIMA, ETS, Theta, MSTL, SeasonalNaive, Naive
Project structure
PowerForecast/
├── forecast-api/ # engine (Python 3.11, FastAPI)
│ ├── app/main.py, config.py, models.py, run_store.py, response_envelope.py
│ ├── app/routers/forecast.py # /jobs, /forecast, /models; execute_forecast_pipeline(); every job stored as a run
│ ├── app/routers/runs.py # /runs: list, read, rename, copy-spec, delete / restore / purge, audit
│ ├── app/store/ # SQLite: db (open + migrations, ids), runs, audit, import_redis
│ ├── db/migrations/*.sql # numbered schema migrations, applied at start
│ ├── app/pipeline/ # robust_profiler, profile_validator, enterprise_model_selector,
│ │ # engine (StatsForecast, evaluate_models), enterprise_diagnostics,
│ │ # transformer, outliers, decision_logger
│ └── tests/ # pytest: engine, pipeline, async jobs (fakeredis), real-data regression
├── forecast-wizard/ # the UI (static files)
│ ├── index.html # shell: navigation, run (PFApp.runForecast, called by step 4), file operations, theme, language
│ ├── wizard.html # the 4 steps; window.importSpec / exportSpec for the shell
│ ├── results.html # Results page: verdict + Overview · Forecast table · Your data · Quality checks
│ ├── css/theme.css, js/theme.js # design tokens and theme switch (same as PowerSolver)
│ ├── css/wizard.css, results.css, result-pages.css, ai-advisor.css
│ ├── js/app.js, wizard-state.js # navigation, Alpine store, buildSpec(), draft keeping (_resume)
│ ├── js/steps/step1-4.js # Goal · Your data · How far ahead · Review
│ ├── js/forecast-results.js # PFResult: the Results page's view model (verdict, checks, rows), pure
│ ├── js/forecast-workbook.js # ForecastWorkbook: Excel/CSV <-> settings + series, pure
│ ├── js/workbook-io.js # WorkbookIO: read/write .xlsx and CSV (ExcelJS in js/vendor/)
│ ├── js/components/ # workbook-panel, assist-panel, powerai-ui, wizard-modal
│ ├── forecasts.html # Forecasts page: the stored runs (RunsAPI)
│ ├── js/services/ # api, runs-api, template-service, powerai-client, ai-advisor, forecast-critic, ai-model-prefs
│ ├── locales/{en,fr,de,es,nl}.json
│ ├── tools/make-demo-kit.mjs # a demo's workbook from its specification
│ └── tests/*.test.mjs # node tests (no dependency)
├── forecast-demos/ # demos; each with input/*.xlsx, SCENARIO, EXPECTED_RESULTS, DEMO_SCRIPT
├── forecast-datasets/ # real public data + provenance (MANIFEST.md)
├── forecast-templates/ # real-data templates used by the engine tests
└── Documentation/ # Markdown guides; tools/build_html.py builds html/
Running locally
# engine (needs Redis: REDIS_URL, default redis://localhost:6379/0)
docker run -p 6379:6379 redis:7-alpine
cd forecast-api && pip install -r requirements.txt && uvicorn app.main:app --reload --port 8020
# wizard (any static server)
cd forecast-wizard && python -m http.server 8094
forecast-wizard/config.json holds engineApiUrl (the engine) and sqliteApiUrl (PowerAdmin, for the template, the languages and AI). Sign-in goes through PowerAdmin (js/ppt-auth.js).
The engine pipeline
execute_forecast_pipeline() (app/routers/forecast.py) wires the stages:
- Profiling:
RobustProfiler.profile_all(). Trend, seasonality (and period), variance type and stationarity, each decided by several methods that must agree (consensus), with a reliability score. Zero profiles is an error (PIPELINE_PRODUCED_EMPTY_RESULTS), never an empty success. - Validation (
validate_patterns):ProfileValidator.validate_all()keeps a detected pattern only if modelling it improves a holdout forecast (Diebold-Mariano test). - Shortlist:
EnterpriseModelSelector.select_models(), candidate methods per series from its profile. - One cross-validation pass:
engine.evaluate_models()scores every candidate and the Naive baseline (MAPE, RMSE, MAE). The lowest CV-RMSE becomes the champion. The result'saccuracycomes from here. - Forecast with the champion (transforms and outlier handling applied), batched by seasonal period.
- Diagnostics reuse the champion's cross-validation residuals (no refit): bias, normality, autocorrelation, heteroscedasticity.
Every choice is written to the decision log (decisions[]), shown under How the method was chosen.
Speed. Profiling with validation plus cross-validation of several candidates costs about 15 – 20 s per monthly series of 11 years on one core (demo 04: 20 series in 5½ – 6 minutes); long daily or weekly series cost more. FORECAST_N_JOBS sets StatsForecast's parallelism (default 1, safe in Docker; -1 = all cores on bare metal). Use /jobs, not /forecast, for many series.
Adding a method: add it to the engine's model factory (create_models in app/pipeline/engine.py) and to the selector's candidates (enterprise_model_selector.py). The cross-validation pass will score it. Then add it to the ForecastModel enum (app/models.py), to forecast-templates/validate_templates.py, and to the template's manual_models in PowerAdmin if users may pick it.
The wizard
- Template-driven. Step questions, help and option lists (targets, granularity, time units, confidence levels, scenarios, methods) come from the PowerForecast template in PowerAdmin (
TemplateService); there is no hard-coded fallback. Template step ids:goal,time,models,meta. Step 3 renderstimeandmodels(the latter under Advanced); step 4 rendersmeta. - State.
Alpine.store('wizard')(js/wizard-state.js);buildSpec()is the exact/jobsbody. The shell reads it throughwindow.exportSpec(). - Drafts. On
pagehidethe wizard keepspfWizardDraft(spec + step) in sessionStorage;WizardApp._resume()restores it, or, withwizard.html#fix=N, loads the last run'sforecastSpecat step N (the Results page's Change the forecast). Reset Wizard clears the draft. - Excel.
ForecastWorkbook.fromSheets(sheets)→{ settings, data, problems };toSheets(spec)writes How to fill / Setup / Data. Both the step 1 panel and the data step use it; CSV goes through the same reader. - A run (
PFApp.runForecast):POST /jobs, poll, fetch result and diagnostics, storeforecastResult,forecastDiagnostics,forecastSpec,forecastRunIdin sessionStorage, save the 4 files, open Results.
The Results page
PFResult.build(result, diagnostics) (js/forecast-results.js) is pure: it returns the verdict, the checks (codes + facts, never sentences), per-series facts and the KPI numbers. results.html turns them into sentences (locales results.*). The rule of the page: a number is shown only when the engine measured it; totals per week/month/… carry no range (PFResult.rows). The AI Forecast Critic calls window.__renderForecastDashboard(result) after a re-forecast; the page then drops the old diagnostics (they belong to the previous run).
Tests
cd forecast-api && python -m pytest tests/ # engine, pipeline, /jobs, real-data regression
cd forecast-wizard && node tests/forecast-results.test.mjs # verdict, checks, rows (any timezone)
cd forecast-wizard && node tests/demo-kits.test.mjs # every demo workbook gives back its specification
Test and demo data are real public series (forecast-datasets/MANIFEST.md).
Demo kits
A demo's workbook is generated, never edited by hand:
node forecast-wizard/tools/make-demo-kit.mjs forecast-demos/<demo>/spec.<name>.json # → <demo>/input/spec.<name>.xlsx
node forecast-wizard/tests/demo-kits.test.mjs
Translations
Every visible text has a key in locales/{en,fr,de,es,nl}.json and an English fallback in the code (_t(key, fallback)). New text: add the key to all five files. data-i18n attributes are applied by js/i18n.js; the shell forwards language changes to the page in the frame.
AI
The engine has no LLM code. Per-step drafting, the pre-flight review, Explain my data and the critic call PowerAI through PowerAdmin (js/services/powerai-client.js), with prompt templates keyed by (producer = PowerForecast, category) in PowerAdmin. The AI model is chosen once, platform-wide (AIModelPrefs).
Configuration (forecast-api)
PORT=8020
LOG_LEVEL=INFO
REDIS_URL=redis://redis:6379/0
RUN_TTL_SECONDS=3600 # finished jobs stay in Redis this long; the stored run stays for good
FORECAST_DB_PATH=/data/powerforecast.db # stored runs (SQLite)
FORECAST_MAX_JSON_MB=50 # larger requests are refused (TOO_LARGE)
ORCHESTRATOR_URL= # optional: fallback retrieval for expired jobs
FORECAST_N_JOBS=1 # StatsForecast parallelism (bare metal: -1)
CORS_ORIGINS=*
Stored runs (SQLite)
app/store/db.py opens FORECAST_DB_PATH with WAL, foreign keys and a 5 s busy timeout, and applies db/migrations/NNNN_name.sql in order at start (table schema_migrations). A released migration is never edited: a change is a new numbered file. Writes take WRITE_LOCK. At start the app also moves the finished jobs still in Redis into the database (import_redis.py, once; they carry no input history) and marks runs a restart interrupted as FAILED. Ids are UUIDv7 text, dates ISO-8601 UTC, every row has created_at/by, updated_at/by, owner, deleted_at/by, and audit_log records each change (actor_verified 0 until the product has its own users) — the same conventions as PowerSolver and PowerDecision. Tests: every test gets its own database (conftest.py).
Documentation
The Markdown files in Documentation/ are the only source. python -I Documentation/tools/build_html.py writes Documentation/html/ (shared template, table of contents, essentials / full switch). --check fails if a page is out of date.