ies-optimiser 2026.9.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ies_optimiser/__init__.py +37 -0
- ies_optimiser/__main__.py +10 -0
- ies_optimiser/_bin/ies-optimiser-thermo.exe +0 -0
- ies_optimiser/_install.py +132 -0
- ies_optimiser/api.py +465 -0
- ies_optimiser/chk.py +317 -0
- ies_optimiser/cli.py +197 -0
- ies_optimiser/data/ies-optimiser-input-1.schema.json +905 -0
- ies_optimiser/data/ies-optimiser-result-1.schema.json +2806 -0
- ies_optimiser/eqs_dmd_e.py +117 -0
- ies_optimiser/eqs_dmd_x.py +55 -0
- ies_optimiser/eqs_flx.py +154 -0
- ies_optimiser/eqs_gen.py +136 -0
- ies_optimiser/eqs_p2x_1.py +118 -0
- ies_optimiser/eqs_p2x_2.py +33 -0
- ies_optimiser/errors.py +169 -0
- ies_optimiser/fcn.py +762 -0
- ies_optimiser/formats.py +392 -0
- ies_optimiser/models.py +537 -0
- ies_optimiser/obj.py +69 -0
- ies_optimiser/opt.py +42 -0
- ies_optimiser/pos.py +283 -0
- ies_optimiser/pos_dmd.py +722 -0
- ies_optimiser/py.typed +0 -0
- ies_optimiser/results.py +323 -0
- ies_optimiser/schemas.py +100 -0
- ies_optimiser-2026.9.0.dist-info/METADATA +100 -0
- ies_optimiser-2026.9.0.dist-info/RECORD +31 -0
- ies_optimiser-2026.9.0.dist-info/WHEEL +5 -0
- ies_optimiser-2026.9.0.dist-info/entry_points.txt +3 -0
- ies_optimiser-2026.9.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# Gréoux Research (2024). IES Optimiser: a linear optimiser-based integrated energy system modelling environment. https://github.com/greoux-research/ies-optimiser
|
|
4
|
+
|
|
5
|
+
"""IES Optimiser: a linear optimiser-based integrated energy system modelling environment.
|
|
6
|
+
|
|
7
|
+
import ies_optimiser
|
|
8
|
+
|
|
9
|
+
report = ies_optimiser.validate('case.json') # nothing solved, nothing written
|
|
10
|
+
result = ies_optimiser.solve('case.json', options={'carbon-constraint': 50.0})
|
|
11
|
+
if result.optimal and result.accounting_ok:
|
|
12
|
+
print(result.document['system']['cost'])
|
|
13
|
+
|
|
14
|
+
The names below are the public API; see docs/ies-optimiser-api.md. The public modules
|
|
15
|
+
are ies_optimiser.api, ies_optimiser.models, ies_optimiser.results, ies_optimiser.errors, ies_optimiser.schemas and
|
|
16
|
+
ies_optimiser.cli. The modelling modules (chk, fcn, eqs_*, obj, opt, pos, pos_dmd,
|
|
17
|
+
formats) are internal, except for fcn.RunConfig, exported here. Importing ies_optimiser
|
|
18
|
+
reads, writes, prints and runs nothing.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from ies_optimiser._install import version as _version
|
|
22
|
+
from ies_optimiser.api import (SolveResult, ValidationReport, check_options, load_input, output_path, parse_case, solve,
|
|
23
|
+
to_canonical, validate, write_result)
|
|
24
|
+
from ies_optimiser.errors import Diagnostic, IesOptimiserError, InputError, ThermoError
|
|
25
|
+
from ies_optimiser.fcn import RunConfig
|
|
26
|
+
from ies_optimiser.models import (Case, CommodityDemand, Demand, ElectricityDemand, Generator, Process, SolveOptions,
|
|
27
|
+
Storage)
|
|
28
|
+
|
|
29
|
+
__version__: str = _version()
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
'__version__',
|
|
33
|
+
'solve', 'validate', 'load_input', 'parse_case', 'to_canonical', 'check_options', 'write_result',
|
|
34
|
+
'output_path', 'SolveResult', 'ValidationReport', 'RunConfig',
|
|
35
|
+
'IesOptimiserError', 'InputError', 'ThermoError', 'Diagnostic',
|
|
36
|
+
'Case', 'Demand', 'ElectricityDemand', 'CommodityDemand', 'Generator', 'Storage', 'Process', 'SolveOptions',
|
|
37
|
+
]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# Gréoux Research (2024). IES Optimiser: a linear optimiser-based integrated energy system modelling environment. https://github.com/greoux-research/ies-optimiser
|
|
4
|
+
|
|
5
|
+
"""python -m ies_optimiser ...: the same command line as the ies-optimiser console command."""
|
|
6
|
+
|
|
7
|
+
from ies_optimiser.cli import main
|
|
8
|
+
|
|
9
|
+
if __name__ == '__main__':
|
|
10
|
+
raise SystemExit(main())
|
|
Binary file
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# Gréoux Research (2024). IES Optimiser: a linear optimiser-based integrated energy system modelling environment. https://github.com/greoux-research/ies-optimiser
|
|
4
|
+
|
|
5
|
+
"""Where this copy of IES Optimiser lives: its version, its files, its checkout (if
|
|
6
|
+
any) and its thermodynamics executable.
|
|
7
|
+
|
|
8
|
+
Nothing here reads outside the package's own directory and its distribution
|
|
9
|
+
metadata, and nothing is written, downloaded or compiled. The version comes
|
|
10
|
+
from the installed distribution's metadata, which pyproject.toml sets (the
|
|
11
|
+
single authoritative version); a source tree that is not installed reads
|
|
12
|
+
pyproject.toml itself.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
from importlib import metadata, resources
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
DISTRIBUTION = 'ies-optimiser'
|
|
21
|
+
|
|
22
|
+
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
"""The directory of the ies_optimiser package that is running (site-packages/ies_optimiser, or src/ies_optimiser)."""
|
|
24
|
+
|
|
25
|
+
THERMO_NAME = 'ies-optimiser-thermo.exe' if sys.platform == 'win32' else 'ies-optimiser-thermo'
|
|
26
|
+
"""The thermodynamics executable's file name on this platform."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def packaged_thermo() -> str:
|
|
30
|
+
"""The absolute path of the thermodynamics executable installed with the
|
|
31
|
+
package, whether or not it exists; never looked up in the working
|
|
32
|
+
directory or a repository.
|
|
33
|
+
|
|
34
|
+
A wheel installs it beside the modules, at ies_optimiser/_bin/, found through the
|
|
35
|
+
package's resources. An editable install keeps the modules in the source
|
|
36
|
+
tree and the built executable in site-packages; there it is the file the
|
|
37
|
+
distribution records as ies_optimiser/_bin/<name> (its RECORD), and only when the
|
|
38
|
+
distribution is an editable install of the running checkout.
|
|
39
|
+
"""
|
|
40
|
+
beside = os.path.abspath(str(resources.files('ies_optimiser').joinpath('_bin', THERMO_NAME)))
|
|
41
|
+
if os.path.isfile(beside):
|
|
42
|
+
return beside
|
|
43
|
+
root = checkout_root()
|
|
44
|
+
dist = _distribution()
|
|
45
|
+
if root is not None and dist is not None and _editable_of(dist, root):
|
|
46
|
+
wanted = '/'.join(('ies_optimiser', '_bin', THERMO_NAME))
|
|
47
|
+
for entry in dist.files or ():
|
|
48
|
+
if str(entry).replace(os.sep, '/') == wanted:
|
|
49
|
+
return os.path.abspath(str(dist.locate_file(entry)))
|
|
50
|
+
return beside
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def checkout_root() -> Optional[str]:
|
|
54
|
+
"""The source checkout this package is running from -- the directory
|
|
55
|
+
holding pyproject.toml above src/ies_optimiser -- or None for an installed wheel."""
|
|
56
|
+
src = os.path.dirname(PACKAGE_DIR)
|
|
57
|
+
root = os.path.dirname(src)
|
|
58
|
+
if os.path.basename(PACKAGE_DIR) == 'ies_optimiser' and os.path.basename(src) == 'src' \
|
|
59
|
+
and os.path.isfile(os.path.join(root, 'pyproject.toml')):
|
|
60
|
+
return root
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _distribution() -> Optional[metadata.Distribution]:
|
|
65
|
+
try:
|
|
66
|
+
return metadata.distribution(DISTRIBUTION)
|
|
67
|
+
except metadata.PackageNotFoundError:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _same(a: str, b: str) -> bool:
|
|
72
|
+
try:
|
|
73
|
+
return os.path.samefile(a, b)
|
|
74
|
+
except OSError:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def installation() -> Dict[str, Any]:
|
|
79
|
+
"""How the running package was installed.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
dict
|
|
84
|
+
``kind``: 'wheel' (an installed distribution whose files are the
|
|
85
|
+
running ones), 'editable' (an editable install of the running
|
|
86
|
+
checkout), 'source' (a checkout on the path but not installed) or
|
|
87
|
+
'unknown'; ``version``; ``package_dir``; ``checkout`` (the source
|
|
88
|
+
checkout's root, or None).
|
|
89
|
+
"""
|
|
90
|
+
root = checkout_root()
|
|
91
|
+
dist = _distribution()
|
|
92
|
+
kind = 'unknown'
|
|
93
|
+
version = None
|
|
94
|
+
if dist is not None:
|
|
95
|
+
installed = dist.locate_file(os.path.join('ies_optimiser', '__init__.py'))
|
|
96
|
+
if _same(str(installed), os.path.join(PACKAGE_DIR, '__init__.py')):
|
|
97
|
+
kind, version = 'wheel', dist.version
|
|
98
|
+
elif root is not None and _editable_of(dist, root):
|
|
99
|
+
kind, version = 'editable', dist.version
|
|
100
|
+
if kind == 'unknown' and root is not None:
|
|
101
|
+
kind, version = 'source', _pyproject_version(root)
|
|
102
|
+
return {'kind': kind, 'version': version, 'package_dir': PACKAGE_DIR, 'checkout': root}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _editable_of(dist: metadata.Distribution, root: str) -> bool:
|
|
106
|
+
# PEP 610: an editable install records the project directory it points to.
|
|
107
|
+
import json
|
|
108
|
+
from urllib.parse import urlparse
|
|
109
|
+
from urllib.request import url2pathname
|
|
110
|
+
text = dist.read_text('direct_url.json')
|
|
111
|
+
if not text:
|
|
112
|
+
return False
|
|
113
|
+
info = json.loads(text)
|
|
114
|
+
if not info.get('dir_info', {}).get('editable'):
|
|
115
|
+
return False
|
|
116
|
+
url = urlparse(info.get('url', ''))
|
|
117
|
+
# url2pathname turns '/C:/dir' into 'C:\\dir' on Windows and unquotes.
|
|
118
|
+
return url.scheme == 'file' and _same(url2pathname(url.path), root)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _pyproject_version(root: str) -> Optional[str]:
|
|
122
|
+
try:
|
|
123
|
+
import tomllib
|
|
124
|
+
with open(os.path.join(root, 'pyproject.toml'), 'rb') as f:
|
|
125
|
+
return str(tomllib.load(f)['project']['version'])
|
|
126
|
+
except (ImportError, OSError, KeyError, ValueError):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def version() -> str:
|
|
131
|
+
"""The version of the running package ('unknown' if it cannot be told)."""
|
|
132
|
+
return installation()['version'] or 'unknown'
|
ies_optimiser/api.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# Gréoux Research (2024). IES Optimiser: a linear optimiser-based integrated energy system modelling environment. https://github.com/greoux-research/ies-optimiser
|
|
4
|
+
|
|
5
|
+
"""The Python API: load an input, validate it, solve it, write the result.
|
|
6
|
+
|
|
7
|
+
from ies_optimiser.api import load_input, validate, solve, write_result
|
|
8
|
+
|
|
9
|
+
path = 'datasets/elec-grid/elec-grid.json'
|
|
10
|
+
report = validate(path) # nothing is solved or written
|
|
11
|
+
if not report.valid:
|
|
12
|
+
for d in report.diagnostics:
|
|
13
|
+
print(d.code, d.path, d.message)
|
|
14
|
+
result = solve(path, options={'carbon-constraint': 50.0})
|
|
15
|
+
if result.optimal and result.accounting_ok:
|
|
16
|
+
print(result.document['system']['cost']) # USD per year
|
|
17
|
+
write_result(result, 'runs/elec-grid.ies-optimiser.carbon-constraint_50.0.json')
|
|
18
|
+
|
|
19
|
+
A case may be given as a path, as a parsed JSON document (canonical, with
|
|
20
|
+
"format_version": 1, or an unversioned legacy document), or as a
|
|
21
|
+
models.Case. Importing this module reads nothing, solves nothing, prints
|
|
22
|
+
nothing, writes nothing and never exits. Nothing is written unless
|
|
23
|
+
write_result() is called. The model, its equations, their construction order
|
|
24
|
+
and the result format are those of the command line (ies-optimiser INPUT.json
|
|
25
|
+
...), which is a thin wrapper around these functions. See docs/ies-optimiser-api.md.
|
|
26
|
+
|
|
27
|
+
Not established: thread safety. Separate calls share no mutable state in
|
|
28
|
+
IES Optimiser itself, but concurrent solves in one process have not been tested.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import dataclasses
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple, Union
|
|
38
|
+
|
|
39
|
+
import numpy as np
|
|
40
|
+
from ortools.linear_solver import pywraplp
|
|
41
|
+
|
|
42
|
+
from ies_optimiser import chk, eqs_dmd_e, eqs_dmd_x, eqs_flx, eqs_gen, eqs_p2x_1, eqs_p2x_2, formats, obj, opt, pos
|
|
43
|
+
from ies_optimiser import fcn as u
|
|
44
|
+
from ies_optimiser.errors import Diagnostic, IesOptimiserError, InputError, ThermoError
|
|
45
|
+
from ies_optimiser.models import Case
|
|
46
|
+
from ies_optimiser.results import RESULT_FORMAT_VERSION
|
|
47
|
+
|
|
48
|
+
PathLike = Union[str, 'os.PathLike[str]']
|
|
49
|
+
CaseLike = Union[Case, Mapping[str, Any], PathLike]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class SolveResult:
|
|
54
|
+
"""The outcome of one solve.
|
|
55
|
+
|
|
56
|
+
Attributes
|
|
57
|
+
----------
|
|
58
|
+
document : dict
|
|
59
|
+
The result, a fresh JSON-compatible dictionary with the structure the
|
|
60
|
+
command line writes (docs/ies-optimiser-io-file-structure.md; JSON Schema:
|
|
61
|
+
ies_optimiser/data/ies-optimiser-result-1.schema.json). When the solve was not optimal it
|
|
62
|
+
holds the case's input fields, the solver status and provenance, and
|
|
63
|
+
no results.
|
|
64
|
+
status : str
|
|
65
|
+
'optimal', 'infeasible', 'unbounded', 'abnormal (numerical trouble)',
|
|
66
|
+
'feasible but not proven optimal' or 'not solved'.
|
|
67
|
+
optimal : bool
|
|
68
|
+
Whether the solver proved an optimal solution.
|
|
69
|
+
accounting_ok : bool or None
|
|
70
|
+
Whether every check in document['system']['checks'] passed; None when
|
|
71
|
+
the solve was not optimal (no accounts exist).
|
|
72
|
+
objective : float or None
|
|
73
|
+
The solver's objective value (USD per year; it omits fixed charges on
|
|
74
|
+
capacities fixed by the input), or None if not optimal.
|
|
75
|
+
"""
|
|
76
|
+
document: Dict[str, Any]
|
|
77
|
+
status: str
|
|
78
|
+
optimal: bool
|
|
79
|
+
accounting_ok: Optional[bool]
|
|
80
|
+
objective: Optional[float]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class ValidationReport:
|
|
85
|
+
"""The outcome of validate(): nothing solved, nothing written.
|
|
86
|
+
|
|
87
|
+
Attributes
|
|
88
|
+
----------
|
|
89
|
+
valid : bool
|
|
90
|
+
True when every stage that ran passed.
|
|
91
|
+
format : str or None
|
|
92
|
+
'canonical' or 'legacy'; None when the document could not be read.
|
|
93
|
+
diagnostics : list of Diagnostic
|
|
94
|
+
Every problem found. Structural validation reports all its problems;
|
|
95
|
+
the later stages stop at the first.
|
|
96
|
+
stages : dict
|
|
97
|
+
Stage name -> 'passed', 'failed', 'not run' or 'not required', for the
|
|
98
|
+
stages 'file', 'options', 'structure', 'semantics' (identifiers,
|
|
99
|
+
references, topology, profiles, hourly shortfall bounds) and
|
|
100
|
+
'thermodynamics'.
|
|
101
|
+
removed_fields : list of str
|
|
102
|
+
JSON Pointers of the output-only fields removed from a legacy document.
|
|
103
|
+
profile_resolution : dict
|
|
104
|
+
{'mode': ..., 'base': ...}, as recorded in a result's provenance.
|
|
105
|
+
case : models.Case or None
|
|
106
|
+
The validated model, when the structure is valid.
|
|
107
|
+
"""
|
|
108
|
+
valid: bool
|
|
109
|
+
format: Optional[str]
|
|
110
|
+
diagnostics: List[Diagnostic]
|
|
111
|
+
stages: Dict[str, str]
|
|
112
|
+
removed_fields: List[str] = field(default_factory=list)
|
|
113
|
+
profile_resolution: Dict[str, Optional[str]] = field(default_factory=dict)
|
|
114
|
+
case: Optional[Case] = None
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
117
|
+
"""A JSON-compatible summary (the model itself is not included)."""
|
|
118
|
+
return {'valid': self.valid, 'format': self.format, 'stages': dict(self.stages),
|
|
119
|
+
'diagnostics': [d.to_dict() for d in self.diagnostics],
|
|
120
|
+
'removed_fields': list(self.removed_fields),
|
|
121
|
+
'profile_resolution': dict(self.profile_resolution)}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_input(path: PathLike) -> Dict[str, Any]:
|
|
125
|
+
"""Read an IES Optimiser input (or result) JSON file.
|
|
126
|
+
|
|
127
|
+
Only parses the file; validation happens in parse_case(), validate() and
|
|
128
|
+
solve(). Profile paths inside the input are left as written. When the case
|
|
129
|
+
is solved, relative ones are resolved against the directory of the file it
|
|
130
|
+
came from -- pass the path to solve() (as `case`, or as `source` with the
|
|
131
|
+
parsed document).
|
|
132
|
+
|
|
133
|
+
Raises
|
|
134
|
+
------
|
|
135
|
+
InputError
|
|
136
|
+
The file cannot be read or is not valid JSON (layer 'file', code
|
|
137
|
+
'input.unreadable', ``entity`` the path).
|
|
138
|
+
"""
|
|
139
|
+
try:
|
|
140
|
+
with open(path, 'r') as f:
|
|
141
|
+
document: Dict[str, Any] = json.load(f)
|
|
142
|
+
return document
|
|
143
|
+
except (OSError, ValueError) as e:
|
|
144
|
+
raise InputError('could not load: ' + str(e), entity=str(path), code='input.unreadable',
|
|
145
|
+
layer='file') from e
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_case(case: Union[Case, Mapping[str, Any]]) -> Case:
|
|
149
|
+
"""Validate a case's structure and return it as a models.Case.
|
|
150
|
+
|
|
151
|
+
Accepts a canonical document, a legacy document (its documented output
|
|
152
|
+
placeholders are removed; see formats.from_legacy) or a Case. One-dimensional
|
|
153
|
+
NumPy arrays are accepted as profiles. Only the structure is checked:
|
|
154
|
+
identifiers, references, profiles and thermodynamics are checked by
|
|
155
|
+
validate() and solve().
|
|
156
|
+
|
|
157
|
+
Raises
|
|
158
|
+
------
|
|
159
|
+
InputError
|
|
160
|
+
One Diagnostic per structural problem found.
|
|
161
|
+
"""
|
|
162
|
+
return formats.parse(case)[0]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def to_canonical(case: Union[Case, Mapping[str, Any]]) -> Dict[str, Any]:
|
|
166
|
+
"""The canonical (format_version 1) JSON document of a case in either
|
|
167
|
+
format: inputs only, every default explicit, numbers as given.
|
|
168
|
+
|
|
169
|
+
Raises
|
|
170
|
+
------
|
|
171
|
+
InputError
|
|
172
|
+
The structure is invalid.
|
|
173
|
+
"""
|
|
174
|
+
return formats.canonical_document(parse_case(case))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def check_options(options: Optional[Mapping[str, Any]]) -> Dict[str, float]:
|
|
178
|
+
"""Validate run options, returning them as floats in the order given.
|
|
179
|
+
|
|
180
|
+
Names and admissible values are those of models.SolveOptions:
|
|
181
|
+
'carbon-constraint' (kg CO2eq per MWh of primary annual electricity demand,
|
|
182
|
+
any finite value) and 'non-served-power-constraint' (a fraction in [0, 1]).
|
|
183
|
+
|
|
184
|
+
Raises
|
|
185
|
+
------
|
|
186
|
+
InputError
|
|
187
|
+
An unknown name, a non-numeric or non-finite value, or a value outside
|
|
188
|
+
its range (layer 'options', ``entity`` 'command line', ``field`` the name).
|
|
189
|
+
"""
|
|
190
|
+
if not options:
|
|
191
|
+
return {}
|
|
192
|
+
return formats.validate_options(options)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _source(case: CaseLike, source: Optional[PathLike]) -> Tuple[Union[Case, Mapping[str, Any]], Optional[PathLike]]:
|
|
196
|
+
# A path is loaded; it is then the source. A contradicting source= is refused.
|
|
197
|
+
if isinstance(case, (str, os.PathLike)):
|
|
198
|
+
if source is not None and os.path.abspath(str(source)) != os.path.abspath(str(case)):
|
|
199
|
+
raise InputError('the case was given as \'' + str(case) + '\' but source= names \''
|
|
200
|
+
+ str(source) + '\'; give one or make them agree', entity='source',
|
|
201
|
+
code='config.conflict', layer='configuration')
|
|
202
|
+
return load_input(case), case
|
|
203
|
+
return case, source
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _profile_base(cfg: u.RunConfig, source: Optional[PathLike]) -> Tuple[u.RunConfig, str]:
|
|
207
|
+
# Where relative profile paths resolve, fixed once for this case.
|
|
208
|
+
base: Optional[str]
|
|
209
|
+
if cfg.profile_base is not None:
|
|
210
|
+
base, mode = os.path.abspath(str(cfg.profile_base)), 'explicit'
|
|
211
|
+
if not os.path.isdir(base):
|
|
212
|
+
raise InputError('profile_base \'' + str(cfg.profile_base) + '\' is not a directory (resolved to \''
|
|
213
|
+
+ base + '\')', entity='profile_base', code='config.profile_base',
|
|
214
|
+
layer='configuration')
|
|
215
|
+
elif source is not None:
|
|
216
|
+
base, mode = os.path.dirname(os.path.abspath(str(source))), 'input-directory'
|
|
217
|
+
else:
|
|
218
|
+
base, mode = None, 'none'
|
|
219
|
+
return dataclasses.replace(cfg, profile_base=base), mode
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def validate(case: CaseLike, *, options: Optional[Mapping[str, Any]] = None,
|
|
223
|
+
config: Optional[u.RunConfig] = None, source: Optional[PathLike] = None,
|
|
224
|
+
thermodynamics: bool = True) -> ValidationReport:
|
|
225
|
+
"""Validate a case completely, without building or solving the problem.
|
|
226
|
+
|
|
227
|
+
Runs, in order: reading the file, the options, the structure (models.Case,
|
|
228
|
+
reporting every problem found), then the semantic checks of
|
|
229
|
+
ies_optimiser/chk.py -- identifiers, references, topology, profiles
|
|
230
|
+
(resolved exactly as solve() resolves them, then read and checked) and
|
|
231
|
+
hourly shortfall bounds -- and finally, with `thermodynamics`, the
|
|
232
|
+
cogeneration coefficients of every heat-supplying unit, which runs the
|
|
233
|
+
thermodynamics executable (config.thermo_bin). A later stage runs only
|
|
234
|
+
when the earlier ones passed.
|
|
235
|
+
|
|
236
|
+
Parameters are those of solve(). Nothing is solved and no file is written.
|
|
237
|
+
|
|
238
|
+
Returns
|
|
239
|
+
-------
|
|
240
|
+
ValidationReport
|
|
241
|
+
Never raises for a problem with the case, the options or the
|
|
242
|
+
configuration: those are reported as diagnostics.
|
|
243
|
+
"""
|
|
244
|
+
cfg = config if config is not None else u.RunConfig()
|
|
245
|
+
stages = {k: 'not run' for k in ('file', 'options', 'structure', 'semantics', 'thermodynamics')}
|
|
246
|
+
fmt: Optional[str] = None
|
|
247
|
+
removed: List[str] = []
|
|
248
|
+
model: Optional[Case] = None
|
|
249
|
+
resolution: Dict[str, Optional[str]] = {}
|
|
250
|
+
|
|
251
|
+
def report(error: Optional[IesOptimiserError], stage: Optional[str]) -> ValidationReport:
|
|
252
|
+
if stage is not None:
|
|
253
|
+
stages[stage] = 'failed'
|
|
254
|
+
return ValidationReport(valid=error is None, format=fmt, diagnostics=list(error.diagnostics) if error else [],
|
|
255
|
+
stages=stages, removed_fields=removed, profile_resolution=resolution, case=model)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
document, source = _source(case, source)
|
|
259
|
+
except InputError as e:
|
|
260
|
+
return report(e, 'file')
|
|
261
|
+
stages['file'] = 'passed' if source is not None else 'not required'
|
|
262
|
+
try:
|
|
263
|
+
opts = check_options(options)
|
|
264
|
+
except InputError as e:
|
|
265
|
+
return report(e, 'options')
|
|
266
|
+
stages['options'] = 'passed'
|
|
267
|
+
try:
|
|
268
|
+
fmt = formats.CANONICAL if isinstance(document, Case) else formats.detect(document)
|
|
269
|
+
model, fmt, removed = formats.parse(document)
|
|
270
|
+
except InputError as e:
|
|
271
|
+
return report(e, 'structure')
|
|
272
|
+
stages['structure'] = 'passed'
|
|
273
|
+
try:
|
|
274
|
+
run, mode = _profile_base(cfg, source)
|
|
275
|
+
except InputError as e:
|
|
276
|
+
return report(e, 'semantics')
|
|
277
|
+
resolution = {'mode': mode, 'base': run.profile_base}
|
|
278
|
+
s = formats.internal_document(model)
|
|
279
|
+
try:
|
|
280
|
+
chk.validate(s, opts, run)
|
|
281
|
+
except InputError as e:
|
|
282
|
+
return report(e, 'semantics')
|
|
283
|
+
stages['semantics'] = 'passed'
|
|
284
|
+
suppliers = chk.heat_suppliers(s)
|
|
285
|
+
if not suppliers:
|
|
286
|
+
stages['thermodynamics'] = 'not required'
|
|
287
|
+
elif thermodynamics:
|
|
288
|
+
try:
|
|
289
|
+
for n, gen, p2x in suppliers:
|
|
290
|
+
chk.cogeneration(gen, p2x, run, path=formats.pointer('generator', n))
|
|
291
|
+
except ThermoError as e:
|
|
292
|
+
return report(e, 'thermodynamics')
|
|
293
|
+
stages['thermodynamics'] = 'passed'
|
|
294
|
+
return report(None, None)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def solve(case: CaseLike, *,
|
|
298
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
299
|
+
config: Optional[u.RunConfig] = None,
|
|
300
|
+
source: Optional[PathLike] = None) -> SolveResult:
|
|
301
|
+
"""Build and solve one IES Optimiser case.
|
|
302
|
+
|
|
303
|
+
Parameters
|
|
304
|
+
----------
|
|
305
|
+
case : models.Case, dict or path
|
|
306
|
+
The input: a Case, a parsed document (canonical or legacy) or a path to
|
|
307
|
+
a JSON file.
|
|
308
|
+
options : mapping, optional
|
|
309
|
+
Run options, e.g. {'carbon-constraint': 50.0,
|
|
310
|
+
'non-served-power-constraint': 0.05}; see check_options().
|
|
311
|
+
config : RunConfig, optional
|
|
312
|
+
Horizon, storage closure, thermodynamics and profile-base settings for
|
|
313
|
+
this run; defaults to a year of 8,760 hours, closed storage, the
|
|
314
|
+
thermodynamics executable installed with the package
|
|
315
|
+
(ies_optimiser/_bin/ies-optimiser-thermo) with a 30 s timeout, and profiles resolved
|
|
316
|
+
against the input file's directory.
|
|
317
|
+
source : path, optional
|
|
318
|
+
The file the case came from, recorded (with its SHA-256) in the
|
|
319
|
+
result's provenance; its directory is where relative profile paths
|
|
320
|
+
resolve unless config.profile_base says otherwise. Set automatically
|
|
321
|
+
when `case` is a path. Without it, provenance records the input as
|
|
322
|
+
'<in-memory>' with the SHA-256 of its canonical JSON serialisation,
|
|
323
|
+
and relative profile paths need config.profile_base.
|
|
324
|
+
|
|
325
|
+
Profile paths: absolute paths are used as written; relative paths resolve
|
|
326
|
+
against config.profile_base if set ('explicit'), otherwise against the
|
|
327
|
+
source file's directory ('input-directory'); an in-memory case with neither
|
|
328
|
+
accepts only inline, empty or absolute profiles ('none'). The working
|
|
329
|
+
directory is never used, and no other location is tried.
|
|
330
|
+
|
|
331
|
+
Returns
|
|
332
|
+
-------
|
|
333
|
+
SolveResult
|
|
334
|
+
Check ``optimal`` and then ``accounting_ok`` before reading any
|
|
335
|
+
figure. An infeasible or otherwise unsuccessful solve is a status, not
|
|
336
|
+
an exception.
|
|
337
|
+
|
|
338
|
+
Side effects: none on the caller's data -- the case, including nested
|
|
339
|
+
lists, dicts and arrays, is copied before use and left unchanged. No file
|
|
340
|
+
is written. Diagnostics go to the 'ies_optimiser' logger.
|
|
341
|
+
|
|
342
|
+
Raises
|
|
343
|
+
------
|
|
344
|
+
InputError
|
|
345
|
+
The case, the options or the configuration are outside the contract,
|
|
346
|
+
or a profile cannot be found where the rule places it; nothing is
|
|
347
|
+
solved. Its diagnostics carry the code, path, entity and hour.
|
|
348
|
+
ThermoError
|
|
349
|
+
Cogeneration coefficients could not be obtained for a heat-supplying
|
|
350
|
+
unit.
|
|
351
|
+
"""
|
|
352
|
+
cfg = config if config is not None else u.RunConfig()
|
|
353
|
+
document_in, source = _source(case, source)
|
|
354
|
+
opts = check_options(options)
|
|
355
|
+
model, fmt, _ = formats.parse(document_in)
|
|
356
|
+
cfg, profile_mode = _profile_base(cfg, source)
|
|
357
|
+
|
|
358
|
+
work = formats.internal_document(model) # private: gains solver objects below
|
|
359
|
+
stat = {'time': time.time(), 'capa': 0, 'outp': 0, 'cons': 0}
|
|
360
|
+
|
|
361
|
+
glop = pywraplp.Solver.CreateSolver('GLOP')
|
|
362
|
+
objective = glop.Objective()
|
|
363
|
+
objective.SetMinimization()
|
|
364
|
+
|
|
365
|
+
chk.define(glop, work, opts, stat, cfg)
|
|
366
|
+
eqs_p2x_1.define(glop, work, opts, stat, cfg)
|
|
367
|
+
eqs_gen.define(glop, work, opts, stat, cfg)
|
|
368
|
+
eqs_p2x_2.define(glop, work, opts, stat, cfg)
|
|
369
|
+
eqs_flx.define(glop, work, opts, stat, cfg)
|
|
370
|
+
emis_con, nspo_con = eqs_dmd_e.define(glop, work, opts, stat, cfg)
|
|
371
|
+
eqs_dmd_x.define(glop, work, opts, stat, cfg)
|
|
372
|
+
|
|
373
|
+
obj.define(objective, work, cfg)
|
|
374
|
+
|
|
375
|
+
success = opt.run(glop, work, opts, stat)
|
|
376
|
+
stat['time'] = time.time() - stat['time']
|
|
377
|
+
|
|
378
|
+
document: Dict[str, Any]
|
|
379
|
+
if success:
|
|
380
|
+
work['solver']['stat_succ'] = 1
|
|
381
|
+
pos.process(glop, work, opts, stat, emis_con, nspo_con, cfg)
|
|
382
|
+
document = work
|
|
383
|
+
objective_value: Optional[float] = objective.Value()
|
|
384
|
+
else:
|
|
385
|
+
# The case's input fields only: no output placeholders, which could be
|
|
386
|
+
# mistaken for results. The working copy is discarded.
|
|
387
|
+
document = formats.input_document(model)
|
|
388
|
+
document['solver'] = {'stat_succ': 0}
|
|
389
|
+
objective_value = None
|
|
390
|
+
|
|
391
|
+
document['provenance'] = u.provenance(None if source is None else str(source), opts, document, cfg, profile_mode)
|
|
392
|
+
if source is None:
|
|
393
|
+
document['provenance']['input'] = '<in-memory>'
|
|
394
|
+
in_memory = formats.canonical_document(document_in) if isinstance(document_in, Case) else document_in
|
|
395
|
+
document['provenance']['input_sha256'] = hashlib.sha256(
|
|
396
|
+
json.dumps(_jsonable(in_memory), sort_keys=True).encode()).hexdigest()
|
|
397
|
+
document['provenance']['input_format'] = fmt
|
|
398
|
+
document['provenance']['result_format_version'] = RESULT_FORMAT_VERSION
|
|
399
|
+
# input_sha256 identifies the file named as the source (or, in memory, the
|
|
400
|
+
# document as given); the case actually solved may differ from that file --
|
|
401
|
+
# a scenario edited in memory -- so it is identified on its own.
|
|
402
|
+
case_digest = _case_digest(model)
|
|
403
|
+
document['provenance']['case_sha256'] = case_digest
|
|
404
|
+
document['provenance']['source_matches_case'] = None if source is None else \
|
|
405
|
+
_source_digest(source) == case_digest
|
|
406
|
+
|
|
407
|
+
document['solver']['stat_status'] = stat.get('status', 'unknown')
|
|
408
|
+
document['solver']['stat_time'] = stat['time']
|
|
409
|
+
document['solver']['stat_capa'] = stat['capa']
|
|
410
|
+
document['solver']['stat_outp'] = stat['outp']
|
|
411
|
+
document['solver']['stat_cons'] = stat['cons']
|
|
412
|
+
|
|
413
|
+
document = _jsonable(document)
|
|
414
|
+
return SolveResult(document=document, status=document['solver']['stat_status'], optimal=bool(success),
|
|
415
|
+
accounting_ok=document['system']['accounting_ok'] if success else None,
|
|
416
|
+
objective=objective_value)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _case_digest(model: Case) -> str:
|
|
420
|
+
"""SHA-256 of the case's canonical JSON (inputs only, every default
|
|
421
|
+
explicit, keys sorted): the same for a legacy document and its canonical
|
|
422
|
+
form, different whenever any input value differs."""
|
|
423
|
+
text = json.dumps(formats.canonical_document(model), sort_keys=True, separators=(',', ':'), allow_nan=False)
|
|
424
|
+
return hashlib.sha256(text.encode('utf-8')).hexdigest()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _source_digest(source: PathLike) -> Optional[str]:
|
|
428
|
+
# The canonical digest of the source file as read now, or None if it no
|
|
429
|
+
# longer reads as a valid case.
|
|
430
|
+
try:
|
|
431
|
+
return _case_digest(formats.parse(load_input(source))[0])
|
|
432
|
+
except IesOptimiserError:
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def write_result(result: Union[SolveResult, Mapping[str, Any]], path: PathLike) -> None:
|
|
437
|
+
"""Write a result as JSON (4-space indented, as the command line does)."""
|
|
438
|
+
document = result.document if isinstance(result, SolveResult) else result
|
|
439
|
+
with open(path, 'w') as f:
|
|
440
|
+
json.dump(document, f, indent=4)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def output_path(input_path: PathLike, options: Optional[Mapping[str, float]] = None) -> str:
|
|
444
|
+
"""The command line's result path: beside the input, '.json' -> '.ies-optimiser.json',
|
|
445
|
+
one '.name_value' segment per option, in the order given."""
|
|
446
|
+
return u.output_path(input_path, dict(options or {}))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _jsonable(node: Any) -> Any:
|
|
450
|
+
"""A copy of `node` in plain JSON types; anything else is a defect and raises."""
|
|
451
|
+
if isinstance(node, dict):
|
|
452
|
+
return {str(k): _jsonable(v) for k, v in node.items()}
|
|
453
|
+
if isinstance(node, (list, tuple)):
|
|
454
|
+
return [_jsonable(v) for v in node]
|
|
455
|
+
if isinstance(node, np.ndarray):
|
|
456
|
+
return [_jsonable(v) for v in node.tolist()]
|
|
457
|
+
if isinstance(node, (bool, np.bool_)):
|
|
458
|
+
return bool(node)
|
|
459
|
+
if isinstance(node, (int, np.integer)):
|
|
460
|
+
return int(node)
|
|
461
|
+
if isinstance(node, (float, np.floating)):
|
|
462
|
+
return float(node)
|
|
463
|
+
if node is None or isinstance(node, str):
|
|
464
|
+
return node
|
|
465
|
+
raise TypeError('result holds a non-JSON value of type ' + type(node).__name__)
|