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.
@@ -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())
@@ -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__)