Skip to content

Commit 83f455d

Browse files
committed
add ref to paper and technical documentation
1 parent a024288 commit 83f455d

15 files changed

+988
-2
lines changed

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
# Generated figures
2727
*.pdf
2828
*.png
29+
*.gif
2930

3031
# Byte-compiled / optimized / DLL files
3132
__pycache__/
@@ -57,3 +58,7 @@ __pycache__/
5758
*.toc
5859
*.xml
5960

61+
# LaTeX build directories
62+
assets/temp/
63+
assets/build/
64+

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525

2626
## Features
2727

28-
***Cost Optimization** - Shift loads optimally to minimize costs based on known electricity prices.
29-
* 🎛️ **Flexible Constraints** - Define how many hours loads can shift earlier or later, transfer rate limits, and power capacity to match your use case.
28+
***Cost Optimization** - Shift loads optimally to minimize costs based on known electricity prices.
29+
* 🎛️ **Flexible Constraints** - Define how many hours loads can shift earlier or later, transfer rate limits, and power capacity to match your use case.
3030
* 📅 **Moving Horizon** - Daily optimization approach that replicates real-world day-ahead market scenarios.
3131

3232
## Example
@@ -127,6 +127,9 @@ optimized = result["results"]
127127
print(optimized.head())
128128
```
129129

130+
## Documentation
131+
Check out our paper [Residential demand response: evaluating how much consumers could actually save](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5956838) for further details on how the package can be used to answer interesting research questions. Also see [TECHNICAL_DOC.md](TECHNICAL_DOC.md) for a more detailed explanation of the transfer matrix formulation and the moving horizon control strategy.
132+
130133
## Development
131134

132135
Install development requirements and set up the hooks:
@@ -161,6 +164,13 @@ Please ensure your code follows our style guidelines:
161164
- Include type annotations for all functions
162165
- Add tests for new functionality
163166

167+
## Citation
168+
169+
If you use this software in your research, please cite:
170+
171+
**Westö, J. & Imam, H. (2025)**, Residential demand response: evaluating how much consumers could actually save.
172+
Available at SSRN: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=5956838
173+
164174
## Acknowledgements
165175
This tool was developed within the "Demand response - Promoting electricity demand response management in
166176
Ostrobothnia" project co-funded by the European Union through the "Just Transition Fund" under the "A Renewing and

TECHNICAL_DOC.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Technical Documentation
2+
Load shifting can be viewed as virtual storage, and the code uses this terminology. In practice, load shifting is implemented via a **Transfer Matrix** `T[i,j]` representing the amount of energy originally demanded at time `i` but purchased at time `j`. Optimizing load shifts thus corresponds to finding values for `T[i,j]` that minimize the cost of procured electricity while satisfying all flexibility constraints.
3+
4+
<div align="center">
5+
<img src="images/transfer_matrix.png" alt="Transfer Matrix Mathematical Formulation" width="400" height="400"/>
6+
</div>
7+
8+
**Key transfer matrix properties:**
9+
- **Row sum** `Σⱼ T[i,j]`: Represents energy removed from time `i`
10+
- **Column sum** `Σᵢ T[i,j]`: Represents energy added to time `j`
11+
12+
**Typical flexibility constraints include:**
13+
1. **Temporal flexibility**: How many time units a load can be shifted earlier or later, defined by which elements in `T[i,j]` can be non-zero. For example, in the visualization above, loads can be shifted 2 hours earlier or 3 hours later.
14+
2. **Maximum power**: The sum of original demand and added energy must not exceed the maximum allowed power at any time.
15+
3. **Energy conservation**: Energy removed from any time period cannot exceed the original demand at that time.
16+
17+
## Moving Horizon Control Strategy
18+
The optimizer provides a **moving horizon control** strategy that facilitates daily optimization runs, mimicking real-world scenarios in which price information becomes available incrementally. For example, day-ahead spot prices for the next day are revealed in the afternoon, enabling day-by-day load optimization. In practice, this iteratively solves small portions of a larger transfer matrix, as shown in the animation below.
19+
20+
<div align="center">
21+
<img src="images/animation.gif" alt="Moving Horizon Animation" width="400" height = "400"/>
22+
</div>
23+
24+
**Key Time Periods:**
25+
- **Lookahead Period**: The whole horizon optimized (the period with available price information).
26+
- **Control Period**: The period that is implemented in practice (typically 24 hours for day by day).
27+
- **History**: Historical decisions from previous optimizations (constraints)
28+
- **Spillover**: Potential Energy transfer to/from the next control period (spillover).
29+
30+
However, this iterative process risks adding/removing consumption to/from the next period if the horizon with known prices is longer than the control horizon (24 hours for day-by-day optimization). Extra bookkeeping is needed to account for this when solving optimization problems over successive days. This bookkeeping is illustrated in the animation above, where spillover from period `n` becomes a historical constraint when optimizing loads for period `n+1`.

assets/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Assets Directory
2+
3+
LaTeX source files and Python scripts for generating documentation images and animations.
4+
5+
## Contents
6+
7+
**LaTeX Sources:**
8+
- `logo.tex`, `transfer_matrix.tex`, `moving_horizon.tex` - Static diagrams
9+
- `transfer_matrix_moving_horizon.tex` - Animated diagram source
10+
- `transfer_matrix.sty` - Shared style package
11+
12+
**Generation Scripts:**
13+
- `generate_images.py` - Generates all PNG images from LaTeX
14+
- `generate_animation.py` - Generates animated GIF
15+
- `pdf_utils.py` - PDF to PNG conversion utilities
16+
17+
## Dependencies
18+
19+
**LaTeX** (plus pdftoppm or Ghostscript for PDF rasterization)
20+
```bash
21+
# Ubuntu/Debian
22+
sudo apt-get install texlive texlive-latex-extra poppler-utils ghostscript
23+
24+
# macOS
25+
brew install --cask mactex
26+
27+
# Windows
28+
# https://miktex.org/download
29+
```
30+
31+
**Python** (Pillow for GIF generation)
32+
```bash
33+
uv sync
34+
```
35+
36+
## Usage
37+
38+
Generate all images and animation:
39+
```bash
40+
uv run assets/generate_images.py
41+
```
42+
43+
Output saved to `images/`:
44+
- `logo.png` (5000 DPI)
45+
- `transfer_matrix.png`, `moving_horizon.png` (600 DPI)
46+
- `animation.gif`
47+
48+
Generate animation only:
49+
```bash
50+
uv run assets/generate_animation.py assets/transfer_matrix_moving_horizon.tex --values "1,7,13,19,25"
51+
```
52+
53+
## Notes
54+
55+
- Scripts auto-detect `pdftoppm` or Ghostscript (typically bundled with LaTeX)
56+
- Temporary build files (`temp/`, `build/`) are auto-cleaned and git-ignored
57+
- Edit `.tex` files and regenerate to update visualizations

assets/generate_animation.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Animate TikZ by sweeping the value of \newcommand{\controlHoursStart}{...}.
4+
Workflow: LaTeX -> PDF -> PNG (pdftoppm or gs) -> GIF (Pillow).
5+
"""
6+
7+
import argparse
8+
import re
9+
import shutil
10+
import subprocess
11+
import sys
12+
from pathlib import Path
13+
14+
from pdf_utils import find_rasterizer, pdf_to_png
15+
from PIL import Image
16+
17+
PATTERN = r'(\\newcommand\s*\{\\controlHoursStart\}\s*\{)(.*?)(\})'
18+
19+
20+
def run(cmd: list[str]) -> None:
21+
subprocess.run(cmd, check=True)
22+
23+
24+
def substitute_control_hours_start(tex: str, value: str) -> str:
25+
if re.search(PATTERN, tex):
26+
return re.sub(PATTERN, r'\g<1>' + str(value) + r'\g<3>', tex)
27+
sys.exit("[ERROR] Couldn't find \\newcommand{\\controlHoursStart}{...} in template")
28+
29+
def make_gif(pngs: list[Path], out_gif: Path, fps: float, loop: int) -> None:
30+
if not pngs:
31+
sys.exit("[ERROR] No PNGs to animate")
32+
dur = max(1, int(1000 / fps))
33+
frames = [Image.open(p).convert("RGBA") for p in pngs]
34+
frames[0].save(out_gif, save_all=True, append_images=frames[1:],
35+
duration=dur, loop=loop, disposal=2)
36+
37+
def main() -> None:
38+
ap = argparse.ArgumentParser()
39+
ap.add_argument("template", type=Path)
40+
ap.add_argument(
41+
"--values",
42+
required=True,
43+
help="Comma-separated values for controlHoursStart",
44+
)
45+
ap.add_argument("--outdir", type=Path, default=Path("build"))
46+
ap.add_argument("--gif", type=Path, default=Path("animation.gif"))
47+
ap.add_argument("--pdflatex", default="pdflatex")
48+
ap.add_argument("--dpi", type=int, default=600)
49+
ap.add_argument("--fps", type=float, default=1)
50+
ap.add_argument("--loop", type=int, default=0)
51+
args = ap.parse_args()
52+
53+
if not shutil.which(args.pdflatex):
54+
sys.exit(f"[ERROR] '{args.pdflatex}' not found")
55+
56+
raster_kind, raster_exec = find_rasterizer()
57+
print(f"[info] Using {raster_kind} at {raster_exec}")
58+
59+
tex_src = args.template.read_text(encoding="utf-8")
60+
values = [v.strip() for v in args.values.split(",") if v.strip()]
61+
args.outdir.mkdir(parents=True, exist_ok=True)
62+
63+
pngs: list[Path] = []
64+
for i, val in enumerate(values):
65+
tex = substitute_control_hours_start(tex_src, val)
66+
tex_path = args.outdir / f"frame_{i:04d}.tex"
67+
pdf_path = args.outdir / f"frame_{i:04d}.pdf"
68+
prefix = args.outdir / f"frame_{i:04d}"
69+
tex_path.write_text(tex, encoding="utf-8")
70+
71+
run([args.pdflatex, "-interaction=nonstopmode", "-halt-on-error",
72+
"-output-directory", str(args.outdir), str(tex_path)])
73+
74+
# Convert PDF to PNG and store the path
75+
png_path = prefix.parent / f"{prefix.name}-1.png"
76+
pdf_to_png(
77+
pdf_path,
78+
png_path,
79+
dpi=args.dpi,
80+
tool=raster_kind,
81+
exe_path=raster_exec,
82+
)
83+
pngs.append(png_path)
84+
85+
make_gif(pngs, args.gif, fps=args.fps, loop=args.loop)
86+
print(f"Done. Frames: {len(pngs)} GIF: {args.gif.resolve()}")
87+
88+
if __name__ == "__main__":
89+
main()

assets/generate_images.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import os
2+
import shutil
3+
from pathlib import Path
4+
5+
from pdf_utils import find_rasterizer, pdf_to_pngs_multipage
6+
7+
# Determine project root (one level up from this script)
8+
SCRIPT_DIR = Path(__file__).resolve().parent
9+
PROJECT_ROOT = SCRIPT_DIR.parent
10+
11+
# Define paths
12+
TEX_DIR = SCRIPT_DIR # assets/
13+
IMAGES_DIR = PROJECT_ROOT / "images"
14+
TEMP_DIR = SCRIPT_DIR / "temp"
15+
16+
# Ensure output directory exists
17+
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
18+
TEMP_DIR.mkdir(parents=True, exist_ok=True)
19+
20+
# Detect PDF rasterizer once at startup
21+
raster_tool, raster_exe = find_rasterizer()
22+
print(f"[info] Using {raster_tool} for PDF rasterization")
23+
24+
# Find all .tex files in the scripts directory
25+
tex_files = list(TEX_DIR.glob("*.tex"))
26+
27+
# Compile LaTeX files and convert to PNG
28+
for tex_file in tex_files:
29+
# Skip transfer_matrix_moving_horizon.tex as it will be processed separately for GIF
30+
if tex_file.name == "transfer_matrix_moving_horizon.tex":
31+
continue
32+
33+
# Compile LaTeX to PDF (run from TEX_DIR to find .sty files)
34+
original_dir = os.getcwd()
35+
os.chdir(TEX_DIR)
36+
os.system(f'pdflatex -output-directory="{TEMP_DIR}" "{tex_file.name}"')
37+
os.chdir(original_dir)
38+
39+
base_name = tex_file.stem
40+
pdf_file = TEMP_DIR / f"{base_name}.pdf"
41+
42+
if pdf_file.exists():
43+
# Convert PDF to images at high resolution (600 DPI for crisp quality)
44+
dpi = 5000 if 'logo' in base_name else 600
45+
46+
# Convert PDF to PNG(s)
47+
temp_prefix = TEMP_DIR / base_name
48+
png_files = pdf_to_pngs_multipage(
49+
pdf_file, temp_prefix, dpi=dpi, tool=raster_tool, exe_path=raster_exe
50+
)
51+
52+
# Move PNGs to final location with appropriate naming
53+
for i, png_path in enumerate(png_files):
54+
if len(png_files) == 1:
55+
output_path = IMAGES_DIR / f"{base_name}.png"
56+
else:
57+
output_path = IMAGES_DIR / f"{base_name}_page_{i+1}.png"
58+
shutil.move(str(png_path), str(output_path))
59+
60+
print(f"Generated: {base_name}.png")
61+
62+
# Clean up temporary files
63+
if TEMP_DIR.exists():
64+
shutil.rmtree(TEMP_DIR)
65+
66+
# Generate animated GIF
67+
gif_tex = TEX_DIR / "transfer_matrix_moving_horizon.tex"
68+
if gif_tex.exists():
69+
make_gif_script = TEX_DIR / "generate_animation.py"
70+
os.system(f'python "{make_gif_script}" "{gif_tex}" --values "1,7,13,19,25"')
71+
72+
# Move animation.gif to images folder (check both root and script dir)
73+
animation_locations = [
74+
PROJECT_ROOT / "animation.gif",
75+
SCRIPT_DIR / "animation.gif"
76+
]
77+
animation_dst = IMAGES_DIR / "animation.gif"
78+
79+
for animation_src in animation_locations:
80+
if animation_src.exists():
81+
shutil.move(str(animation_src), str(animation_dst))
82+
print("Generated: animation.gif")
83+
break
84+
85+
# Clean up build folder if it exists
86+
build_dir = SCRIPT_DIR / "build"
87+
if build_dir.exists():
88+
shutil.rmtree(build_dir)

0 commit comments

Comments
 (0)