Skip to content

Commit

Permalink
refactor: generate doc tests in shell script
Browse files Browse the repository at this point in the history
  • Loading branch information
ADGEfficiency committed May 19, 2024
1 parent 0152b00 commit e0e4cfd
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 85 deletions.
28 changes: 4 additions & 24 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ setup-docs:
# ----- TEST -----
# documentation tests and unit tests

.PHONY: test test-ci test-docs clean-test-docs test-validate create-test-docs
.PHONY: test generate-test-docs test-docs
PARALLEL = auto
TEST_ARGS =
export
Expand All @@ -52,30 +52,10 @@ test: setup-test test-docs
-coverage report
python tests/assert-test-coverage.py $(TEST_ARGS)

create-test-docs: setup-test clean-test-docs
mkdir -p ./tests/phmdoctest
python -m phmdoctest README.md --outfile tests/phmdoctest/test_readme.py
python -m phmdoctest ./docs/docs/changelog.md --outfile tests/phmdoctest/test_changelog.md
python -m phmdoctest ./docs/docs/how-to/complex-terms.md --outfile tests/phmdoctest/test_complex_terms.py
python -m phmdoctest ./docs/docs/how-to/custom-objectives.md --outfile tests/phmdoctest/test_custom_objectives.py
python -m phmdoctest ./docs/docs/validation/battery.md --outfile tests/phmdoctest/test_validate_battery.py
python -m phmdoctest ./docs/docs/validation/evs.md --outfile tests/phmdoctest/test_validate_evs.py
python -m phmdoctest ./docs/docs/validation/heat-pump.md --outfile tests/phmdoctest/test_validate_heat-pump.py
python -m phmdoctest ./docs/docs/validation/renewable-generator.md --outfile tests/phmdoctest/test_validate_renewable_generator.py
python -m phmdoctest ./docs/docs/how-to/dispatch-forecast.md --outfile tests/phmdoctest/test_forecast.py
python -m phmdoctest ./docs/docs/how-to/price-carbon.md --outfile tests/phmdoctest/test_carbon.py
python -m phmdoctest ./docs/docs/how-to/dispatch-site.md --outfile tests/phmdoctest/test_dispatch_site.py
python -m phmdoctest ./docs/docs/assets/chp.md --outfile tests/phmdoctest/test_optimize_chp.py
python -m phmdoctest ./docs/docs/assets/battery.md --outfile tests/phmdoctest/test_optimize_battery.py
python -m phmdoctest ./docs/docs/assets/evs.md --outfile tests/phmdoctest/test_optimize_evs.py
python -m phmdoctest ./docs/docs/assets/heat-pump.md --outfile tests/phmdoctest/test_optimize_heat_pump.py
python -m phmdoctest ./docs/docs/assets/chp.md --outfile tests/phmdoctest/test_optimize_chp.py
python -m phmdoctest ./docs/docs/assets/renewable-generator.md --outfile tests/phmdoctest/test_optimize_renewable_generator.py

clean-test-docs:
rm -rf ./tests/phmdoctest
generate-test-docs: setup-test
bash ./tests/generate-test-docs.sh

test-docs: clean-test-docs create-test-docs
test-docs: setup-test generate-test-docs
pytest tests/phmdoctest -n $(PARALLEL) --dist loadfile --color=yes --verbose $(TEST_ARGS)


Expand Down
80 changes: 71 additions & 9 deletions docs/docs/how-to/custom-constraints.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
## Context

In linear programming, constraints define the feasible region of the program. They are how you control what is and isn't possible in a program.
In linear programming, constraints define the feasible region of the linear program. They are how you control what is and isn't possible in a simulation.

The assets and site in `energypylinear` apply a range of constraints to the linear program, ranging from electricity balances around a battery to constraining how much electricity can be generated from a renewable generator.
The assets and site in `energypylinear` apply constraints to the linear program, ranging from electricity balances around a battery to constraining how much electricity can be generated from a renewable generator.

In addition, `energypylinear` allows you to define your own, custom constraints.

**A custom constraint allows you to construct constraints that can control what can and cannot happen based on what is important to you**.

## Custom Constraint

<!--phmdoctest-mark.skip-->
```python
--8<-- "energypylinear/constraints.py:constraint"
--8 < --"energypylinear/constraints.py:constraint"
```

A custom constraint has:
Expand Down Expand Up @@ -39,21 +40,31 @@ If supplied as dictionary, the constraint term will be coerced to an `epl.Constr

Core to the custom objective function is the `epl.ConstraintTerm`, which represents a single term in a constraint:

<!--phmdoctest-mark.skip-->
```python
--8<-- "energypylinear/constraints.py:constraint-term"
--8 < --"energypylinear/constraints.py:constraint-term"
```

## Examples

### Limiting Battery Cycles

The example below shows how to optimize a battery with a constraint on battery cycles.

We define battery cycles as the sum of the total battery charge and discharge, and constraint it to be less than or equal to 15 cycles of 2 MWh per cycle:

```python
import energypylinear as epl
import numpy as np

np.random.seed(42)

cycle_limit = 2 * 15
asset = epl.Battery(
power_mw=1,
capacity_mwh=2,
efficiency_pct=0.98,
electricity_prices=np.random.normal(0.0, 1000, 48 * 7),
constraints=[
epl.Constraint(
lhs=[
Expand All @@ -64,20 +75,71 @@ asset = epl.Battery(
asset_type="battery", variable="electric_discharge_mwh"
),
],
rhs=2 * 15,
sense="le"
rhs=cycle_limit,
sense="le",
interval_aggregation="sum",
)
],
)
simulation = asset.optimize(verbose=3)
total_cycles = simulation.results.sum()[
["battery-electric_charge_mwh", "battery-electric_discharge_mwh"]
].sum()
print(total_cycles)
```

After simulation we can see our total cycles are constrained to an upper limit of 30 (with a small floating point error):

```
30.000000002
```

### Constraining Renewable Generation
### Constraining Total Generation

Constrain renewable generator to a percentage of total generation.
The example below shows how to constrain the total generation in a site.

Use a CHP with cheap gas
We define a site with a solar and electric generator asset, with the available solar power increasing with time:

```python
import energypylinear as epl
import numpy as np

np.random.seed(42)

idx_len = 4
generator_size = 100
solar_gen = [10.0, 20, 30, 40]
site = epl.Site(
assets=[
epl.RenewableGenerator(
electric_generation_mwh=solar_gen,
name="solar",
electric_generation_lower_bound_pct=0.0,
),
epl.CHP(electric_power_max_mw=generator_size, electric_efficiency_pct=0.5),
],
electricity_prices=np.full(idx_len, 400),
gas_prices=10,
constraints=[
{
"lhs": {"variable": "electric_generation_mwh", "asset_type": "*"},
"rhs": 25,
"sense": "le",
}
],
)
simulation = site.optimize(verbose=3)
print(
simulation.results[["chp-electric_generation_mwh", "solar-electric_generation_mwh", "total-electric_generation_mwh"]]
)
```

After simulation

```
chp-electric_generation_mwh solar-electric_generation_mwh total-electric_generation_mwh
0 15.0 10.0 25.0
1 5.0 20.0 25.0
2 0.0 25.0 25.0
3 0.0 25.0 25.0
```
2 changes: 1 addition & 1 deletion energypylinear/assets/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ def __init__(
freq_mins: int = defaults.freq_mins,
import_limit_mw: float = 10000,
export_limit_mw: float = 10000,
constraints: "list[epl.Constraint | dict] | None" = None,
constraints: "list[epl.Constraint] | list[dict] | None" = None,
):
"""Initialize a Site asset model."""
self.assets = assets
Expand Down
69 changes: 28 additions & 41 deletions energypylinear/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,22 @@ def parse_dicts_to_constraint_terms(
if isinstance(value, (float, ConstraintTerm)):
return value

elif isinstance(value, dict):
return ConstraintTerm(**value)
# elif isinstance(value, dict):
# return ConstraintTerm(**value)

terms = []
values = value
assert isinstance(values, list)
for t in values:
"""
if isinstance(t, (float, ConstraintTerm)):
terms.append(t)
else:
assert isinstance(t, dict)
terms.append(ConstraintTerm(**t))
"""
assert isinstance(t, (float, ConstraintTerm))
terms.append(t)

assert len(terms) == len(values)
return terms
Expand Down Expand Up @@ -127,50 +131,33 @@ def _resolve_constraint_term(
if isinstance(term, (float, int)):
return [term / (len(interval_data.idx) if divide_constant_by_idx_len else 1)]

if isinstance(term, dict):
term = epl.ConstraintTerm(**term)
# if isinstance(term, dict):
# term = epl.ConstraintTerm(**term)

assert isinstance(term, epl.ConstraintTerm)

if i is not None:
vars = ivars.filter_objective_variables(
i=i, instance_type=term.asset_type, asset_name=term.asset_name
)
"""
TODO - interesting thing here
vars = ivars.filter_objective_variables(
i=i, instance_type=term.asset_type, asset_name=term.asset_name
)
"""
TODO - interesting thing here
for a Site, `electric_generation_mwh` is None
for a Site, `electric_generation_mwh` is None
I do tackle this issue elsewhere - cannot remember where at the moment
I do tackle this issue elsewhere - cannot remember where at the moment
Why wouldn't I have `electric_generation_mwh` be 0 ?
"""
return [
(getattr(v, term.variable) if getattr(v, term.variable) is not None else 0)
* (
getattr(interval_data, term.interval_data)[i]
if term.interval_data is not None
else 1
)
* term.coefficient
for v in vars
]
else:
vars = ivars.filter_objective_variables_all_intervals(
instance_type=term.asset_type, asset_name=term.asset_name
Why wouldn't I have `electric_generation_mwh` be 0 ?
"""
return [
(getattr(v, term.variable) if getattr(v, term.variable) is not None else 0)
* (
getattr(interval_data, term.interval_data)[i]
if term.interval_data is not None
else 1
)
assert len(vars) == len(interval_data.idx)
return [
(getattr(v, term.variable) if getattr(v, term.variable) is not None else 0)
* (
getattr(interval_data, term.interval_data)[i]
if term.interval_data is not None
else 1
)
* term.coefficient
for i, interval in enumerate(vars)
for v in interval
]
* term.coefficient
for v in vars
]


def _add_terms(
Expand Down Expand Up @@ -232,8 +219,8 @@ def add_custom_constraint(
constraint = epl.Constraint(**constraint)

if constraint.interval_aggregation == "sum":
lhs = []
rhs = []
lhs: list = []
rhs: list = []
for i in interval_data.idx:
_add_terms(
constraint.lhs,
Expand Down
2 changes: 1 addition & 1 deletion energypylinear/interval_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def filter_objective_variables(
) -> list[AssetOneInterval]:
"""Filters objective variables based on type, interval index, and asset name."""
if isinstance(instance_type, str):
type_mapper: dict[str, type] = {
type_mapper: dict[str, type | None] = {
"battery": epl.assets.battery.BatteryOneInterval,
"boiler": epl.assets.boiler.BoilerOneInterval,
"chp": epl.assets.chp.CHPOneInterval,
Expand Down
26 changes: 26 additions & 0 deletions tests/generate-test-docs.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash

generate_tests() {
local input_path=$1
local output_dir="tests/phmdoctest"
local base=$(basename "$input_path" .md)
local output_file="$output_dir/test_${base}.py"

echo "Generating test for $input_path to $output_file"
python -m phmdoctest "$input_path" --outfile "$output_file"
}

echo "Removing Previous Tests"
rm -rf ./tests/phmdoctest
mkdir ./tests/phmdoctest

echo "Processing README.md"
generate_tests "README.md"

echo "Processing Markdown files in ./docs/docs"
find ./docs/docs -name "*.md" -print0 | while IFS= read -r -d '' file; do
generate_tests "$file"
done

echo "Don't Test Changelog"
rm ./tests/phmdoctest/test_changelog.py
20 changes: 11 additions & 9 deletions tests/test_custom_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
([10 + 20], [20 + 30]),
],
)
def test_no_all_constants(lhs: tuple | float, rhs: tuple | float) -> None:
def test_no_all_constants(
lhs: float | epl.ConstraintTerm | dict | list[float | epl.ConstraintTerm | dict],
rhs: float | epl.ConstraintTerm | dict | list[float | epl.ConstraintTerm | dict],
) -> None:
"""Tests that we raise errors when we have all floats on both sides of a constraint."""
with pytest.raises(ValueError):
epl.Constraint(lhs=lhs, rhs=rhs, sense="le")
Expand Down Expand Up @@ -176,13 +179,12 @@ def test_no_all_constants(lhs: tuple | float, rhs: tuple | float) -> None:


@pytest.mark.parametrize(
"custom_constraints, expected_n_extra_constraints",
test_custom_constraints_params,
"custom_constraints, expected_n_extra_constraints", test_custom_constraints_params
)
def test_custom_constraint_combinations(
custom_constraints,
expected_n_extra_constraints,
):
custom_constraints: list,
expected_n_extra_constraints: int,
) -> None:
"""Tests many combinations of custom constraint terms:
- contants,
- asset types,
Expand Down Expand Up @@ -222,7 +224,7 @@ def test_custom_constraint_combinations(
asset_second = epl.Battery(name="battery-zwei")

# now do with dictionaries
constraints = [c.dict() for c in constraints]
constraints_dicts = [c.dict() for c in constraints]
no_constraint_site = epl.Site(
assets=[asset_one, asset_second, epl.Spill()],
electricity_prices=np.random.uniform(-100, 100, 48),
Expand All @@ -232,7 +234,7 @@ def test_custom_constraint_combinations(
site = epl.Site(
assets=[asset_one, asset_second, epl.Spill()],
electricity_prices=np.random.uniform(-100, 100, 48),
constraints=constraints,
constraints=constraints_dicts,
)
site.optimize(verbose=3)
n_extra_constraints = len(site.optimizer.constraints()) - len(
Expand Down Expand Up @@ -429,7 +431,7 @@ def test_battery_cycle_constraint_multiple_batteries() -> None:
)


def test_limit_sum_generation_in_each_interval():
def test_limit_sum_generation_in_each_interval() -> None:
"""Test that we can constrain the sum of two generators within each interval."""

idx_len = 4
Expand Down

0 comments on commit e0e4cfd

Please sign in to comment.