pyghe 0.1.0__tar.gz

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.
Files changed (40) hide show
  1. pyghe-0.1.0/LICENSE +21 -0
  2. pyghe-0.1.0/PKG-INFO +154 -0
  3. pyghe-0.1.0/README.md +114 -0
  4. pyghe-0.1.0/pyproject.toml +40 -0
  5. pyghe-0.1.0/setup.cfg +4 -0
  6. pyghe-0.1.0/src/pyghe/__init__.py +46 -0
  7. pyghe-0.1.0/src/pyghe/_base.py +278 -0
  8. pyghe-0.1.0/src/pyghe/_case.py +536 -0
  9. pyghe-0.1.0/src/pyghe/_cli.py +396 -0
  10. pyghe-0.1.0/src/pyghe/batch.py +267 -0
  11. pyghe-0.1.0/src/pyghe/borehole_resistance.py +66 -0
  12. pyghe-0.1.0/src/pyghe/building_loads.py +52 -0
  13. pyghe-0.1.0/src/pyghe/errors.py +56 -0
  14. pyghe-0.1.0/src/pyghe/ground_heat_exchanger.py +111 -0
  15. pyghe-0.1.0/src/pyghe/gshp_system.py +34 -0
  16. pyghe-0.1.0/src/pyghe/heat_carrier_fluid.py +13 -0
  17. pyghe-0.1.0/src/pyghe/heat_pump_performance.py +96 -0
  18. pyghe-0.1.0/src/pyghe/project.py +644 -0
  19. pyghe-0.1.0/src/pyghe/results.py +474 -0
  20. pyghe-0.1.0/src/pyghe.egg-info/PKG-INFO +154 -0
  21. pyghe-0.1.0/src/pyghe.egg-info/SOURCES.txt +38 -0
  22. pyghe-0.1.0/src/pyghe.egg-info/dependency_links.txt +1 -0
  23. pyghe-0.1.0/src/pyghe.egg-info/requires.txt +23 -0
  24. pyghe-0.1.0/src/pyghe.egg-info/top_level.txt +1 -0
  25. pyghe-0.1.0/tests/test_batch.py +98 -0
  26. pyghe-0.1.0/tests/test_bounds.py +133 -0
  27. pyghe-0.1.0/tests/test_case.py +182 -0
  28. pyghe-0.1.0/tests/test_cli.py +115 -0
  29. pyghe-0.1.0/tests/test_docs.py +80 -0
  30. pyghe-0.1.0/tests/test_errors.py +153 -0
  31. pyghe-0.1.0/tests/test_examples.py +30 -0
  32. pyghe-0.1.0/tests/test_matrix.py +169 -0
  33. pyghe-0.1.0/tests/test_nodes.py +140 -0
  34. pyghe-0.1.0/tests/test_notebooks.py +72 -0
  35. pyghe-0.1.0/tests/test_pickle.py +72 -0
  36. pyghe-0.1.0/tests/test_plume.py +148 -0
  37. pyghe-0.1.0/tests/test_project.py +70 -0
  38. pyghe-0.1.0/tests/test_results.py +156 -0
  39. pyghe-0.1.0/tests/test_save.py +117 -0
  40. pyghe-0.1.0/tests/test_server.py +136 -0
pyghe-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philippe Pasquier
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pyghe-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyghe
3
+ Version: 0.1.0
4
+ Summary: Python interface to the simulation kernel of GHE Analysis: ground heat exchangers, g-functions, borehole resistance, sizing
5
+ Author: Philippe Pasquier
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://www.gheanalysis.com
8
+ Project-URL: Documentation, https://pyghe.readthedocs.io
9
+ Project-URL: Repository, https://github.com/ppasquier/pyghe
10
+ Project-URL: Issues, https://github.com/ppasquier/pyghe/issues
11
+ Project-URL: Changelog, https://pyghe.readthedocs.io/en/latest/changelog.html
12
+ Keywords: geothermal,ground heat exchanger,borehole,g-function,heat pump,simulation,thermal plume,groundwater flow
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: numpy>=1.24
21
+ Provides-Extra: pandas
22
+ Requires-Dist: pandas>=2; extra == "pandas"
23
+ Provides-Extra: notebooks
24
+ Requires-Dist: matplotlib>=3.8; extra == "notebooks"
25
+ Requires-Dist: pandas>=2; extra == "notebooks"
26
+ Requires-Dist: jupyterlab>=4; extra == "notebooks"
27
+ Provides-Extra: docs
28
+ Requires-Dist: sphinx>=7; extra == "docs"
29
+ Requires-Dist: myst-nb>=1.1; extra == "docs"
30
+ Requires-Dist: numpydoc>=1.7; extra == "docs"
31
+ Requires-Dist: sphinx-rtd-theme>=2; extra == "docs"
32
+ Provides-Extra: test
33
+ Requires-Dist: pytest>=7; extra == "test"
34
+ Requires-Dist: pandas>=2; extra == "test"
35
+ Requires-Dist: matplotlib>=3.8; extra == "test"
36
+ Requires-Dist: nbformat>=5; extra == "test"
37
+ Requires-Dist: nbclient>=0.9; extra == "test"
38
+ Requires-Dist: ipykernel>=6; extra == "test"
39
+ Dynamic: license-file
40
+
41
+ # pyghe
42
+
43
+ [GHE Analysis](https://www.gheanalysis.com/pages/ghe-analysis) is a desktop application for the
44
+ design and simulation of ground heat exchangers. pyghe runs its kernel from Python: borehole
45
+ resistance, g-functions, hourly entering fluid temperatures and the length a borehole field needs.
46
+ It is the same code as the application, so a script and the interface give the same answer.
47
+
48
+ ```python skip
49
+ import pyghe as ga
50
+
51
+ p = ga.Project.load("my_project.ghe") # a .ghe file, or a Project built in Python
52
+ p.ground_heat_exchanger.active_length = 180
53
+ result = p.simulate()
54
+ print(result.eft.min()) # entering fluid temperature, °C
55
+ ```
56
+
57
+ ## Installing
58
+
59
+ ```console
60
+ pip install pyghe
61
+ ```
62
+
63
+ You need Python 3.10 or later, numpy, and a Large or Academic licence of Analysis Suite.
64
+ [Installation](docs/install.md) explains how pyghe finds the executable and how to check that
65
+ everything is in place.
66
+
67
+ ## Quick start
68
+
69
+ Two heat pumps on four boreholes and a building that heats in winter and cools in summer. The
70
+ script prints the coldest fluid temperature the heat pumps see over 25 years:
71
+
72
+ ```python
73
+ import numpy as np
74
+ import pyghe as ga
75
+
76
+ p = ga.Project()
77
+ p.simulation_duration = 25
78
+ p.building_loads = ga.HourlyBuildingLoadsCombined(
79
+ combined_demand=30 * np.cos(2 * np.pi * np.arange(8760) / 8760)) # kW, heating +
80
+ p.gshp_system.system_type = "Water-water (zone)"
81
+ p.gshp_system.number_of_heat_pumps = 2
82
+ p.gshp_system.flow_rate_of_pump_a = ga.ByMode(heating=120, cooling=120) # L/min
83
+ p.heat_pump_performance.temperature_limit = ga.ByMode(heating=0.0, cooling=30.0) # °C
84
+ p.heat_pump_performance.performance_data = ga.PerformanceConstant(
85
+ cooling_cap=20, cooling_cop=4.5, heating_cap=15, heating_cop=3.5)
86
+ p.heat_carrier_fluid = ga.HeatCarrierFluid("Propylene glycol", antifreeze_mass_concentration=25)
87
+ p.borehole_resistance = ga.SingleULoop(
88
+ borehole_diameter=0.152, grout_thermal_conductivity=1.25, shank_spacing=0.1,
89
+ pipe_inner_diameter=0.033, pipe_outer_diameter=0.044, pipe_thermal_conductivity=0.39)
90
+ g = p.ground_heat_exchanger
91
+ g.ground_thermal_conductivity, g.ground_volumetric_capacity = 2.5, 2.2
92
+ g.mean_surface_temperature, g.geothermal_heat_flux = 10.0, 60
93
+ g.active_length, g.burial_depth = 150, 2
94
+ g.coordinates = ga.Coordinates.rectangle(nx=2, ny=2, spacing=6.0)
95
+
96
+ print(p.simulate().eft.min()) # °C
97
+ ```
98
+
99
+ The nodes are filled in the order of the project tree: loads, heat pumps, fluid, borehole, ground.
100
+ A project saved from a script opens in the application, which recomputes it and shows what a
101
+ script leaves aside. [Getting started](docs/getting-started.md) builds a larger example step by
102
+ step.
103
+
104
+ ## Documentation
105
+
106
+ The documentation starts at [docs/index.md](docs/index.md). To build it as HTML:
107
+
108
+ ```console
109
+ pip install -e .[docs]
110
+ python -m sphinx -b html docs docs/_build/html
111
+ ```
112
+
113
+ ## Citing
114
+
115
+ pyghe is GHE Analysis driven from Python, so please cite the application.
116
+
117
+ > Pasquier, P. (2025). *GHE Analysis – Interactive simulation of ground heat exchangers*. P³
118
+ > Geothermal inc. <https://doi.org/10.5281/zenodo.15660526>
119
+
120
+ [Citing](docs/citing.md) and [Methods](docs/methods.md) list the papers behind each method.
121
+
122
+ ## Contact
123
+
124
+ Bug reports and suggestions are welcome as [issues on GitHub](https://github.com/ppasquier/pyghe/issues), or through
125
+ [www.gheanalysis.com](https://www.gheanalysis.com).
126
+
127
+ ## Working on the package
128
+
129
+ ```console
130
+ git clone https://github.com/ppasquier/pyghe
131
+ cd pyghe
132
+ python -m venv .venv
133
+ .venv/Scripts/python -m pip install -e .[test,docs]
134
+ set PYGHE_CLI=C:\...\AnalysisSuite-cli.exe
135
+ .venv/Scripts/python -m pytest -q
136
+ ```
137
+
138
+ The executable is the one of the [download page](https://www.gheanalysis.com/pages/download), unpacked as
139
+ [Installation](docs/install.md) describes. Without `PYGHE_CLI` or the executable where pyghe looks for
140
+ it, the tests that run it are skipped. They need a Large or Academic licence
141
+ activated on the computer.
142
+
143
+ I test the documentation like the code: `tests/test_docs.py` runs every code block of this page
144
+ and of `docs/` against the executable, so an example that stops working fails the build. A block
145
+ fenced `python skip` is shown without being run. `tests/test_notebooks.py` checks the committed
146
+ outputs of the notebooks; setting `PYGHE_TEST_NOTEBOOKS=1` also runs them afresh, which takes
147
+ about a minute.
148
+
149
+ [CONTRIBUTING.md](CONTRIBUTING.md) says how to propose a change.
150
+
151
+ ## Licence
152
+
153
+ pyghe is released under the MIT licence ([LICENSE](LICENSE)). It needs `AnalysisSuite-cli`, which is
154
+ distributed with GHE Analysis under its own licence terms.
pyghe-0.1.0/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # pyghe
2
+
3
+ [GHE Analysis](https://www.gheanalysis.com/pages/ghe-analysis) is a desktop application for the
4
+ design and simulation of ground heat exchangers. pyghe runs its kernel from Python: borehole
5
+ resistance, g-functions, hourly entering fluid temperatures and the length a borehole field needs.
6
+ It is the same code as the application, so a script and the interface give the same answer.
7
+
8
+ ```python skip
9
+ import pyghe as ga
10
+
11
+ p = ga.Project.load("my_project.ghe") # a .ghe file, or a Project built in Python
12
+ p.ground_heat_exchanger.active_length = 180
13
+ result = p.simulate()
14
+ print(result.eft.min()) # entering fluid temperature, °C
15
+ ```
16
+
17
+ ## Installing
18
+
19
+ ```console
20
+ pip install pyghe
21
+ ```
22
+
23
+ You need Python 3.10 or later, numpy, and a Large or Academic licence of Analysis Suite.
24
+ [Installation](docs/install.md) explains how pyghe finds the executable and how to check that
25
+ everything is in place.
26
+
27
+ ## Quick start
28
+
29
+ Two heat pumps on four boreholes and a building that heats in winter and cools in summer. The
30
+ script prints the coldest fluid temperature the heat pumps see over 25 years:
31
+
32
+ ```python
33
+ import numpy as np
34
+ import pyghe as ga
35
+
36
+ p = ga.Project()
37
+ p.simulation_duration = 25
38
+ p.building_loads = ga.HourlyBuildingLoadsCombined(
39
+ combined_demand=30 * np.cos(2 * np.pi * np.arange(8760) / 8760)) # kW, heating +
40
+ p.gshp_system.system_type = "Water-water (zone)"
41
+ p.gshp_system.number_of_heat_pumps = 2
42
+ p.gshp_system.flow_rate_of_pump_a = ga.ByMode(heating=120, cooling=120) # L/min
43
+ p.heat_pump_performance.temperature_limit = ga.ByMode(heating=0.0, cooling=30.0) # °C
44
+ p.heat_pump_performance.performance_data = ga.PerformanceConstant(
45
+ cooling_cap=20, cooling_cop=4.5, heating_cap=15, heating_cop=3.5)
46
+ p.heat_carrier_fluid = ga.HeatCarrierFluid("Propylene glycol", antifreeze_mass_concentration=25)
47
+ p.borehole_resistance = ga.SingleULoop(
48
+ borehole_diameter=0.152, grout_thermal_conductivity=1.25, shank_spacing=0.1,
49
+ pipe_inner_diameter=0.033, pipe_outer_diameter=0.044, pipe_thermal_conductivity=0.39)
50
+ g = p.ground_heat_exchanger
51
+ g.ground_thermal_conductivity, g.ground_volumetric_capacity = 2.5, 2.2
52
+ g.mean_surface_temperature, g.geothermal_heat_flux = 10.0, 60
53
+ g.active_length, g.burial_depth = 150, 2
54
+ g.coordinates = ga.Coordinates.rectangle(nx=2, ny=2, spacing=6.0)
55
+
56
+ print(p.simulate().eft.min()) # °C
57
+ ```
58
+
59
+ The nodes are filled in the order of the project tree: loads, heat pumps, fluid, borehole, ground.
60
+ A project saved from a script opens in the application, which recomputes it and shows what a
61
+ script leaves aside. [Getting started](docs/getting-started.md) builds a larger example step by
62
+ step.
63
+
64
+ ## Documentation
65
+
66
+ The documentation starts at [docs/index.md](docs/index.md). To build it as HTML:
67
+
68
+ ```console
69
+ pip install -e .[docs]
70
+ python -m sphinx -b html docs docs/_build/html
71
+ ```
72
+
73
+ ## Citing
74
+
75
+ pyghe is GHE Analysis driven from Python, so please cite the application.
76
+
77
+ > Pasquier, P. (2025). *GHE Analysis – Interactive simulation of ground heat exchangers*. P³
78
+ > Geothermal inc. <https://doi.org/10.5281/zenodo.15660526>
79
+
80
+ [Citing](docs/citing.md) and [Methods](docs/methods.md) list the papers behind each method.
81
+
82
+ ## Contact
83
+
84
+ Bug reports and suggestions are welcome as [issues on GitHub](https://github.com/ppasquier/pyghe/issues), or through
85
+ [www.gheanalysis.com](https://www.gheanalysis.com).
86
+
87
+ ## Working on the package
88
+
89
+ ```console
90
+ git clone https://github.com/ppasquier/pyghe
91
+ cd pyghe
92
+ python -m venv .venv
93
+ .venv/Scripts/python -m pip install -e .[test,docs]
94
+ set PYGHE_CLI=C:\...\AnalysisSuite-cli.exe
95
+ .venv/Scripts/python -m pytest -q
96
+ ```
97
+
98
+ The executable is the one of the [download page](https://www.gheanalysis.com/pages/download), unpacked as
99
+ [Installation](docs/install.md) describes. Without `PYGHE_CLI` or the executable where pyghe looks for
100
+ it, the tests that run it are skipped. They need a Large or Academic licence
101
+ activated on the computer.
102
+
103
+ I test the documentation like the code: `tests/test_docs.py` runs every code block of this page
104
+ and of `docs/` against the executable, so an example that stops working fails the build. A block
105
+ fenced `python skip` is shown without being run. `tests/test_notebooks.py` checks the committed
106
+ outputs of the notebooks; setting `PYGHE_TEST_NOTEBOOKS=1` also runs them afresh, which takes
107
+ about a minute.
108
+
109
+ [CONTRIBUTING.md](CONTRIBUTING.md) says how to propose a change.
110
+
111
+ ## Licence
112
+
113
+ pyghe is released under the MIT licence ([LICENSE](LICENSE)). It needs `AnalysisSuite-cli`, which is
114
+ distributed with GHE Analysis under its own licence terms.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyghe"
7
+ version = "0.1.0"
8
+ description = "Python interface to the simulation kernel of GHE Analysis: ground heat exchangers, g-functions, borehole resistance, sizing"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = ["numpy>=1.24"]
12
+ authors = [{ name = "Philippe Pasquier" }]
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
15
+ keywords = ["geothermal", "ground heat exchanger", "borehole", "g-function", "heat pump", "simulation", "thermal plume", "groundwater flow"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Science/Research",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://www.gheanalysis.com"
25
+ Documentation = "https://pyghe.readthedocs.io"
26
+ Repository = "https://github.com/ppasquier/pyghe"
27
+ Issues = "https://github.com/ppasquier/pyghe/issues"
28
+ Changelog = "https://pyghe.readthedocs.io/en/latest/changelog.html"
29
+
30
+ [project.optional-dependencies]
31
+ pandas = ["pandas>=2"]
32
+ notebooks = ["matplotlib>=3.8", "pandas>=2", "jupyterlab>=4"]
33
+ docs = ["sphinx>=7", "myst-nb>=1.1", "numpydoc>=1.7", "sphinx-rtd-theme>=2"]
34
+ test = ["pytest>=7", "pandas>=2", "matplotlib>=3.8", "nbformat>=5", "nbclient>=0.9", "ipykernel>=6"]
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["src"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
pyghe-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,46 @@
1
+ """Python interface to the GHE module of Analysis Suite.
2
+
3
+ import pyghe as ga
4
+ p = ga.Project()
5
+ p.ground_heat_exchanger.ground_thermal_conductivity = 2.5
6
+ ...
7
+ result = p.simulate()
8
+
9
+ Names and units follow the interface of GHE Analysis (SI): each attribute of a Project is a
10
+ node of the Analysis Tree. Every number is computed by the application's kernel through
11
+ AnalysisSuite-cli.
12
+ """
13
+
14
+ from ._base import ByMode
15
+ from ._case import DEFAULT_SERIES, SERIES
16
+ from ._cli import close_servers, configure, find_cli
17
+ from .batch import run_many, sweep
18
+ from .borehole_resistance import Coaxial, DoubleULoopA, DoubleULoopD, SingleULoop, StandingColumn
19
+ from .building_loads import (HourlyBuildingLoadsCombined, HourlyBuildingLoadsSeparate, HourlyGroundLoadsCombined,
20
+ MonthlyBuildingLoadsSeparate)
21
+ from .errors import (CliError, CliNotFoundError, ConvergenceWarning, PygheError, InvalidInputError,
22
+ KernelError, KernelWarning, LicenseError, NotConvergedError, OutsideInterfaceWarning,
23
+ SeriesNotRequested)
24
+ from .ground_heat_exchanger import Coordinates, GroundHeatExchanger
25
+ from .gshp_system import SYSTEM_TYPES, GSHPSystem
26
+ from .heat_carrier_fluid import FLUIDS, HeatCarrierFluid
27
+ from .heat_pump_performance import HeatPumpPerformance, ManufacturerData, PerformanceConstant, PerformanceTemperature
28
+ from .project import PLUME_READINGS, Project, ProjectDescription, SizingTool, fluid_properties
29
+ from .results import (BoreholeResistanceResult, FluidProperties, GFunctionResult, Result, SimulationResult,
30
+ SizingResult, ThermalPlume2DResult, ThermalPlume3DResult, load_result)
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "BoreholeResistanceResult", "ByMode", "CliError", "CliNotFoundError", "Coaxial",
36
+ "ConvergenceWarning", "Coordinates", "DEFAULT_SERIES", "DoubleULoopA", "DoubleULoopD",
37
+ "FLUIDS", "FluidProperties", "GFunctionResult", "GSHPSystem", "PygheError",
38
+ "GroundHeatExchanger", "HeatCarrierFluid", "HeatPumpPerformance",
39
+ "HourlyBuildingLoadsCombined", "HourlyBuildingLoadsSeparate", "HourlyGroundLoadsCombined",
40
+ "InvalidInputError", "KernelError", "KernelWarning", "LicenseError", "ManufacturerData",
41
+ "MonthlyBuildingLoadsSeparate", "NotConvergedError", "OutsideInterfaceWarning", "PLUME_READINGS",
42
+ "PerformanceConstant", "PerformanceTemperature", "Project", "ProjectDescription", "Result", "SERIES", "SYSTEM_TYPES",
43
+ "SeriesNotRequested", "SimulationResult", "SingleULoop", "SizingResult", "SizingTool",
44
+ "StandingColumn", "ThermalPlume2DResult", "ThermalPlume3DResult", "close_servers", "configure",
45
+ "find_cli", "fluid_properties", "load_result", "run_many", "sweep",
46
+ ]
@@ -0,0 +1,278 @@
1
+ """Common machinery of the project's nodes: slots, validation on assignment, units, repr."""
2
+
3
+ import dataclasses
4
+ import math
5
+
6
+ import numpy as np
7
+
8
+ from .errors import InvalidInputError
9
+
10
+ HOURS_PER_YEAR = 8760
11
+
12
+
13
+ def param(unit="", check=None, default=None, factory=None, symbol=""):
14
+ """A node field: its unit and symbol as the interface shows them (both printed by repr)
15
+ and the check run on every assignment. None always means 'not set' and is never checked."""
16
+ meta = {"unit": unit, "check": check, "symbol": symbol}
17
+ if factory is not None:
18
+ return dataclasses.field(default_factory=factory, metadata=meta)
19
+ return dataclasses.field(default=default, metadata=meta)
20
+
21
+
22
+ def private():
23
+ """A field the user does not set: not a constructor argument, not compared, not printed."""
24
+ return dataclasses.field(default=None, init=False, metadata={"private": True})
25
+
26
+
27
+ def _public(obj):
28
+ return [f for f in dataclasses.fields(obj) if not f.metadata.get("private")]
29
+
30
+
31
+ _checks_cache = {}
32
+
33
+
34
+ def _checks_of(cls):
35
+ checks = _checks_cache.get(cls)
36
+ if checks is None:
37
+ checks = {f.name: f.metadata.get("check") for f in dataclasses.fields(cls)}
38
+ _checks_cache[cls] = checks
39
+ return checks
40
+
41
+
42
+ class Node:
43
+ """Base of every node. Subclasses are dataclasses with slots, so that assigning a
44
+ misspelt attribute raises AttributeError instead of creating a new field."""
45
+
46
+ __slots__ = ()
47
+
48
+ def __setattr__(self, name, value):
49
+ check = _checks_of(type(self)).get(name)
50
+ if check is not None and value is not None:
51
+ value = check(value, f"{type(self).__name__}.{name}")
52
+ object.__setattr__(self, name, value)
53
+
54
+ def __eq__(self, other):
55
+ if type(other) is not type(self):
56
+ return NotImplemented
57
+ for f in _public(self):
58
+ a, b = getattr(self, f.name), getattr(other, f.name)
59
+ if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
60
+ if a is None or b is None or not np.array_equal(a, b, equal_nan=True):
61
+ return False
62
+ elif a != b:
63
+ return False
64
+ return True
65
+
66
+ __hash__ = None
67
+
68
+ def __repr__(self):
69
+ return "\n".join(self._describe())
70
+
71
+ def _describe(self, indent=""):
72
+ lines = [f"{indent}{type(self).__name__}"]
73
+ for f in _public(self):
74
+ value = getattr(self, f.name)
75
+ symbol = f.metadata.get("symbol", "")
76
+ name = f"{f.name} ({symbol})" if symbol else f.name
77
+ if isinstance(value, Node):
78
+ sub = value._describe(indent + " ")
79
+ lines.append(f"{indent} {name}: {sub[0].strip()}")
80
+ lines.extend(sub[1:])
81
+ elif isinstance(value, ByMode):
82
+ lines.append(f"{indent} {name} = {value!r}")
83
+ else:
84
+ lines.append(f"{indent} {name} = {_show(value, f.metadata.get('unit', ''))}")
85
+ return lines
86
+
87
+
88
+ def _show(value, unit):
89
+ if value is None:
90
+ return "(not set)"
91
+ if isinstance(value, np.ndarray):
92
+ if value.size == 0:
93
+ text = "[] (0 values)"
94
+ else:
95
+ text = f"[{value.min():.6g} … {value.max():.6g}] ({value.size} values)"
96
+ else:
97
+ text = repr(value)
98
+ return f"{text} {unit}".rstrip()
99
+
100
+
101
+ # Checks. Each returns the value to store, converted, or raises InvalidInputError naming the
102
+ # attribute. Those taking settings are classes, not closures: a ByMode keeps its check, and
103
+ # pickle (multiprocessing, joblib) refuses local functions.
104
+
105
+ def real(value, where):
106
+ if isinstance(value, bool):
107
+ raise InvalidInputError(f"{where} must be a number, got {value!r}")
108
+ try:
109
+ x = float(value)
110
+ except (TypeError, ValueError):
111
+ raise InvalidInputError(f"{where} must be a number, got {value!r}") from None
112
+ if not math.isfinite(x):
113
+ raise InvalidInputError(f"{where} must be finite, got {x}")
114
+ return x
115
+
116
+
117
+ def positive(value, where):
118
+ x = real(value, where)
119
+ if x <= 0:
120
+ raise InvalidInputError(f"{where} must be > 0, got {x}")
121
+ return x
122
+
123
+
124
+ def non_negative(value, where):
125
+ x = real(value, where)
126
+ if x < 0:
127
+ raise InvalidInputError(f"{where} must be >= 0, got {x}")
128
+ return x
129
+
130
+
131
+ class between:
132
+ def __init__(self, low, high, low_open=False, high_open=False):
133
+ self.low, self.high, self.low_open, self.high_open = low, high, low_open, high_open
134
+
135
+ def __call__(self, value, where):
136
+ x = real(value, where)
137
+ low, high = self.low, self.high
138
+ if (x < low or (self.low_open and x == low)) or (x > high or (self.high_open and x == high)):
139
+ lo = "(" if self.low_open else "["
140
+ hi = ")" if self.high_open else "]"
141
+ raise InvalidInputError(f"{where} must lie in {lo}{low}, {high}{hi}, got {x}")
142
+ return x
143
+
144
+
145
+ class integer:
146
+ def __init__(self, minimum=None, choices=None):
147
+ self.minimum, self.choices = minimum, choices
148
+
149
+ def __call__(self, value, where):
150
+ if isinstance(value, bool) or not float(real(value, where)).is_integer():
151
+ raise InvalidInputError(f"{where} must be an integer, got {value!r}")
152
+ n = int(value)
153
+ if self.minimum is not None and n < self.minimum:
154
+ raise InvalidInputError(f"{where} must be >= {self.minimum}, got {n}")
155
+ if self.choices is not None and n not in self.choices:
156
+ raise InvalidInputError(f"{where} must be one of {', '.join(map(str, self.choices))}, got {n}")
157
+ return n
158
+
159
+
160
+ class one_of:
161
+ def __init__(self, *choices):
162
+ self.choices = choices
163
+
164
+ def __call__(self, value, where):
165
+ if value not in self.choices:
166
+ raise InvalidInputError(f"{where} must be one of {', '.join(map(repr, self.choices))}, got {value!r}")
167
+ return value
168
+
169
+
170
+ class instance_of:
171
+ def __init__(self, *classes):
172
+ self.classes = classes
173
+
174
+ def __call__(self, value, where):
175
+ if not isinstance(value, self.classes):
176
+ names = " or ".join(c.__name__ for c in self.classes)
177
+ raise InvalidInputError(f"{where} must be a {names}, got {type(value).__name__}")
178
+ return value
179
+
180
+
181
+ class series:
182
+ """A read-only float64 array. Reassign the attribute to change it: an array edited in place
183
+ would bypass the checks."""
184
+
185
+ def __init__(self, length=None, minimum_length=1, allow_nan=False):
186
+ self.length, self.minimum_length, self.allow_nan = length, minimum_length, allow_nan
187
+
188
+ def __call__(self, value, where):
189
+ try:
190
+ a = np.array(value, dtype=np.float64, copy=True)
191
+ except (TypeError, ValueError):
192
+ raise InvalidInputError(f"{where} must be an array of numbers") from None
193
+ if a.ndim != 1:
194
+ raise InvalidInputError(f"{where} must be one-dimensional, got shape {a.shape}")
195
+ if self.length is not None and a.size != self.length:
196
+ raise InvalidInputError(f"{where} must hold {self.length} values, got {a.size}")
197
+ if a.size < self.minimum_length:
198
+ raise InvalidInputError(f"{where} must hold at least {self.minimum_length} value(s)")
199
+ bad = ~np.isfinite(a) if not self.allow_nan else np.isinf(a)
200
+ if bad.any():
201
+ raise InvalidInputError(f"{where}[{int(np.argmax(bad))}] is not finite")
202
+ a.setflags(write=False)
203
+ return a
204
+
205
+
206
+ def node(dc=None, kw_only=True):
207
+ """Decorator: a slotted keyword-only dataclass deriving from Node."""
208
+ def wrap(cls):
209
+ return dataclasses.dataclass(slots=True, kw_only=kw_only, repr=False, eq=False)(cls)
210
+ return wrap(dc) if dc is not None else wrap
211
+
212
+
213
+ class ByMode:
214
+ """A pair of values, one for heating and one for cooling. Assigned to a node, it takes the
215
+ unit and the check of that attribute, which then apply to each of its two values."""
216
+
217
+ __slots__ = ("heating", "cooling", "_check", "_unit", "_where")
218
+
219
+ def __init__(self, heating=None, cooling=None):
220
+ object.__setattr__(self, "_check", None)
221
+ object.__setattr__(self, "_unit", "")
222
+ object.__setattr__(self, "_where", "ByMode")
223
+ self.heating = heating
224
+ self.cooling = cooling
225
+
226
+ def __setattr__(self, name, value):
227
+ if name in ("heating", "cooling") and value is not None and self._check is not None:
228
+ value = self._check(value, f"{self._where}.{name}")
229
+ object.__setattr__(self, name, value)
230
+
231
+ def __eq__(self, other):
232
+ return (isinstance(other, ByMode) and self.heating == other.heating
233
+ and self.cooling == other.cooling)
234
+
235
+ def __deepcopy__(self, memo):
236
+ twin = ByMode()
237
+ for name in self.__slots__:
238
+ object.__setattr__(twin, name, getattr(self, name))
239
+ return twin
240
+
241
+ def __reduce__(self):
242
+ return _restore_by_mode, tuple(getattr(self, name) for name in self.__slots__)
243
+
244
+ def __repr__(self):
245
+ return (f"ByMode(heating={_show(self.heating, self._unit)}, "
246
+ f"cooling={_show(self.cooling, self._unit)})")
247
+
248
+
249
+ def _restore_by_mode(*values):
250
+ pair = ByMode()
251
+ for name, value in zip(ByMode.__slots__, values):
252
+ object.__setattr__(pair, name, value)
253
+ return pair
254
+
255
+
256
+ class by_mode:
257
+ """Check of an attribute holding a ByMode. Accepts a ByMode, a (heating, cooling) pair or
258
+ a dict with those keys; returns a new ByMode bound to check and unit."""
259
+
260
+ def __init__(self, check, unit=""):
261
+ self.check, self.unit = check, unit
262
+
263
+ def __call__(self, value, where):
264
+ if isinstance(value, ByMode):
265
+ h, c = value.heating, value.cooling
266
+ elif isinstance(value, dict) and set(value) <= {"heating", "cooling"}:
267
+ h, c = value.get("heating"), value.get("cooling")
268
+ elif isinstance(value, (tuple, list)) and len(value) == 2:
269
+ h, c = value
270
+ else:
271
+ raise InvalidInputError(f"{where} must be a ByMode(heating=..., cooling=...)")
272
+ pair = ByMode()
273
+ object.__setattr__(pair, "_check", self.check)
274
+ object.__setattr__(pair, "_unit", self.unit)
275
+ object.__setattr__(pair, "_where", where)
276
+ pair.heating = h
277
+ pair.cooling = c
278
+ return pair