OpenSimula 0.8.7__tar.gz → 0.8.8__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 (76) hide show
  1. {opensimula-0.8.7/src/opensimula.egg-info → opensimula-0.8.8}/PKG-INFO +18 -1
  2. {opensimula-0.8.7 → opensimula-0.8.8}/README.md +17 -0
  3. {opensimula-0.8.7 → opensimula-0.8.8}/pyproject.toml +1 -1
  4. opensimula-0.8.8/src/opensimula/Iterative_process.py +50 -0
  5. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Building.py +17 -3
  6. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Construction.py +9 -5
  7. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/File_met.py +80 -58
  8. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/HVAC_SZW_system.py +22 -22
  9. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Space.py +4 -1
  10. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/visual_3D/Environment_3D.py +23 -5
  11. {opensimula-0.8.7 → opensimula-0.8.8/src/opensimula.egg-info}/PKG-INFO +18 -1
  12. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_Case_600FF.py +3 -3
  13. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_Case_610.py +20 -4
  14. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_Construction.py +35 -0
  15. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_File_met.py +19 -0
  16. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_HVAC_DX_system.py +12 -12
  17. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_HVAC_FAN_COIL_water_system.py +10 -10
  18. opensimula-0.8.8/test/test_Iterative_process.py +30 -0
  19. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_editor_schema.py +15 -9
  20. opensimula-0.8.7/src/opensimula/Iterative_process.py +0 -47
  21. opensimula-0.8.7/test/test_Iterative_process.py +0 -13
  22. {opensimula-0.8.7 → opensimula-0.8.8}/LICENSE +0 -0
  23. {opensimula-0.8.7 → opensimula-0.8.8}/setup.cfg +0 -0
  24. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/Child.py +0 -0
  25. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/Component.py +0 -0
  26. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/Message.py +0 -0
  27. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/Parameter_container.py +0 -0
  28. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/Parameters.py +0 -0
  29. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/Project.py +0 -0
  30. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/Simulation.py +0 -0
  31. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/Variable.py +0 -0
  32. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/__init__.py +0 -0
  33. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Building_surface.py +0 -0
  34. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Calculator.py +0 -0
  35. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Chiller_heat_pump.py +0 -0
  36. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/DX_unit.py +0 -0
  37. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Day_schedule.py +0 -0
  38. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Fan.py +0 -0
  39. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/File_data.py +0 -0
  40. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Frame.py +0 -0
  41. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Glazing.py +0 -0
  42. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/HVAC_DX_system.py +0 -0
  43. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/HVAC_MZW_system.py +0 -0
  44. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/HVAC_perfect_system.py +0 -0
  45. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/HVAC_water_system.py +0 -0
  46. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Material.py +0 -0
  47. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Opening.py +0 -0
  48. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Opening_type.py +0 -0
  49. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Pump.py +0 -0
  50. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Solar_surface.py +0 -0
  51. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Space_type.py +0 -0
  52. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Surface.py +0 -0
  53. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Test_component.py +0 -0
  54. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Water_coil.py +0 -0
  55. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Week_schedule.py +0 -0
  56. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/Year_schedule.py +0 -0
  57. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/components/__init__.py +0 -0
  58. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/editor/__init__.py +0 -0
  59. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/editor/schema.py +0 -0
  60. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/editor/static/editor.css +0 -0
  61. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/editor/static/editor.js +0 -0
  62. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/editor/validation.py +0 -0
  63. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/editor/widget.py +0 -0
  64. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula/visual_3D/Polygon_3D.py +0 -0
  65. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula.egg-info/SOURCES.txt +0 -0
  66. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula.egg-info/dependency_links.txt +0 -0
  67. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula.egg-info/requires.txt +0 -0
  68. {opensimula-0.8.7 → opensimula-0.8.8}/src/opensimula.egg-info/top_level.txt +0 -0
  69. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_Calculator.py +0 -0
  70. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_File_data.py +0 -0
  71. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_HVAC_DX_Equipment.py +0 -0
  72. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_HVAC_water_system_process.py +0 -0
  73. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_Project.py +0 -0
  74. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_Schedules.py +0 -0
  75. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_editor_widget.py +0 -0
  76. {opensimula-0.8.7 → opensimula-0.8.8}/test/test_underground_repeated_simulation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensimula
3
- Version: 0.8.7
3
+ Version: 0.8.8
4
4
  Summary: Component-based time simulation environment in Python
5
5
  Author-email: "Juan F. Coronel" <jfc@us.es>
6
6
  Requires-Python: >=3.12
@@ -24,8 +24,10 @@ Dynamic: license-file
24
24
 
25
25
  # OpenSimula
26
26
 
27
+ [![Tests](https://github.com/jfCoronel/OpenSimula/actions/workflows/tests.yml/badge.svg)](https://github.com/jfCoronel/OpenSimula/actions/workflows/tests.yml)
27
28
  [![PyPI version](https://img.shields.io/pypi/v/opensimula)](https://pypi.org/project/opensimula/)
28
29
  [![Python versions](https://img.shields.io/pypi/pyversions/opensimula)](https://pypi.org/project/opensimula/)
30
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23210985.svg)](https://doi.org/10.5281/zenodo.23210985)
29
31
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
30
32
 
31
33
  **OpenSimula** is a component-based time simulation environment in Python, focused on the
@@ -162,6 +164,21 @@ reference, is at [jfcoronel.github.io/OpenSimula](https://jfcoronel.github.io/Op
162
164
  Bugs and feature requests are tracked on
163
165
  [GitHub Issues](https://github.com/jfCoronel/OpenSimula/issues).
164
166
 
167
+ ## Contributing
168
+
169
+ Contributions are welcome: bug reports, fixes, new components, validation cases and examples.
170
+ See [CONTRIBUTING.md](https://github.com/jfCoronel/OpenSimula/blob/main/CONTRIBUTING.md) for how to set up a development environment, run the
171
+ tests and submit a pull request.
172
+
173
+ ## Citing OpenSimula
174
+
175
+ If you use OpenSimula in your research, please cite it with its DOI,
176
+ [10.5281/zenodo.23210985](https://doi.org/10.5281/zenodo.23210985), which always resolves to
177
+ the latest version (each version also has its own DOI, listed on
178
+ [Zenodo](https://doi.org/10.5281/zenodo.23210985)). The full metadata is in
179
+ [CITATION.cff](https://github.com/jfCoronel/OpenSimula/blob/main/CITATION.cff) (GitHub's "Cite this repository" button generates APA and BibTeX
180
+ entries from it).
181
+
165
182
  ## Main Developers
166
183
 
167
184
  * [Juan F. Coronel](http://jfc.us.es), Universidad de Sevilla
@@ -2,8 +2,10 @@
2
2
 
3
3
  # OpenSimula
4
4
 
5
+ [![Tests](https://github.com/jfCoronel/OpenSimula/actions/workflows/tests.yml/badge.svg)](https://github.com/jfCoronel/OpenSimula/actions/workflows/tests.yml)
5
6
  [![PyPI version](https://img.shields.io/pypi/v/opensimula)](https://pypi.org/project/opensimula/)
6
7
  [![Python versions](https://img.shields.io/pypi/pyversions/opensimula)](https://pypi.org/project/opensimula/)
8
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23210985.svg)](https://doi.org/10.5281/zenodo.23210985)
7
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
10
 
9
11
  **OpenSimula** is a component-based time simulation environment in Python, focused on the
@@ -140,6 +142,21 @@ reference, is at [jfcoronel.github.io/OpenSimula](https://jfcoronel.github.io/Op
140
142
  Bugs and feature requests are tracked on
141
143
  [GitHub Issues](https://github.com/jfCoronel/OpenSimula/issues).
142
144
 
145
+ ## Contributing
146
+
147
+ Contributions are welcome: bug reports, fixes, new components, validation cases and examples.
148
+ See [CONTRIBUTING.md](https://github.com/jfCoronel/OpenSimula/blob/main/CONTRIBUTING.md) for how to set up a development environment, run the
149
+ tests and submit a pull request.
150
+
151
+ ## Citing OpenSimula
152
+
153
+ If you use OpenSimula in your research, please cite it with its DOI,
154
+ [10.5281/zenodo.23210985](https://doi.org/10.5281/zenodo.23210985), which always resolves to
155
+ the latest version (each version also has its own DOI, listed on
156
+ [Zenodo](https://doi.org/10.5281/zenodo.23210985)). The full metadata is in
157
+ [CITATION.cff](https://github.com/jfCoronel/OpenSimula/blob/main/CITATION.cff) (GitHub's "Cite this repository" button generates APA and BibTeX
158
+ entries from it).
159
+
143
160
  ## Main Developers
144
161
 
145
162
  * [Juan F. Coronel](http://jfc.us.es), Universidad de Sevilla
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opensimula"
3
- version = "0.8.7"
3
+ version = "0.8.8"
4
4
  requires-python = ">=3.12"
5
5
  authors = [
6
6
  { name="Juan F. Coronel", email="jfc@us.es" },
@@ -0,0 +1,50 @@
1
+ import math
2
+
3
+ class Iterative_process:
4
+ def __init__(self, x_0, tol = 1e-5, lambda_min=0.1, lambda_max=1.0, n_ini_relax=2,rel_vel = 0.9):
5
+ """
6
+ Iterative process using the fixed-point method with adaptive relaxation coefficient
7
+
8
+ Parameters:
9
+ x_0: Initial value
10
+ tol: convergence tolerance
11
+ lambda_min, lambda_max: relaxation coefficient range
12
+ n_ini_relax: Number of iterations in which adaptive relaxation is initiated, must be greater than 1
13
+ """
14
+ self.x = [x_0]
15
+ self.tol = tol
16
+ self.lambda_min = lambda_min
17
+ self.lambda_max = lambda_max
18
+ self.lambda_i = lambda_max
19
+ self.n_ini_relax = n_ini_relax
20
+ self.rel_vel = rel_vel
21
+ self.residual = None # |proposed_x - x| of the last iteration
22
+
23
+ def converged(self):
24
+ # Fixed-point residual, not the relaxed step: with relaxation lambda the step is only
25
+ # lambda * residual, so testing the step stopped with residuals up to tol / lambda_min
26
+ # (10 x tol), e.g. zones far from their moisture balance in steady state
27
+ return self.residual is not None and self.residual < self.tol
28
+
29
+ def estimate_next_x(self, proposed_x):
30
+ residual = proposed_x - self.x[-1]
31
+ x_next = (1 - self.lambda_i) * self.x[-1] + self.lambda_i * proposed_x
32
+
33
+ # Adaptive relaxation: reduce lambda when the iteration oscillates (the residual
34
+ # changes sign) or does not contract (the residual grows), and recover it while it
35
+ # contracts; reducing it unconditionally made well-behaved iterations crawl
36
+ # (lambda_min after ~10 iterations)
37
+ if len(self.x) > self.n_ini_relax and self.residual is not None:
38
+ if residual * self._last_residual < 0 or abs(residual) > self.residual:
39
+ self.lambda_i = max(self.lambda_min, self.lambda_i * self.rel_vel)
40
+ else:
41
+ self.lambda_i = min(self.lambda_max, self.lambda_i / self.rel_vel)
42
+ self._last_residual = residual
43
+ self.residual = abs(residual)
44
+
45
+ self.x.append(x_next)
46
+ return x_next
47
+
48
+ def set_next_x(self, next_x):
49
+ self.x.append(next_x)
50
+
@@ -85,7 +85,7 @@ class Building(Component):
85
85
  SWR_matrix = (SWR_matrix - np.matmul(self.ff_matrix, rho_matrix)
86
86
  - np.matmul(self.ff_matrix, np.matmul(tau_matrix, self.B_matrix)))
87
87
 
88
- SWR_matrix = np.linalg.inv(SWR_matrix)
88
+ SWR_matrix = _radiosity_inverse_(SWR_matrix)
89
89
  aux_matrix = np.matmul(area_matrix, np.matmul(alpha_matrix, SWR_matrix))
90
90
  self.SWDIF_matrix = np.matmul(aux_matrix, np.matmul(self.ff_matrix, tau_matrix)) # SW Solar Diffuse
91
91
 
@@ -119,7 +119,7 @@ class Building(Component):
119
119
  LWR_matrix = (LWR_matrix - np.matmul(self.ff_matrix, rho_matrix)
120
120
  - np.matmul(self.ff_matrix, np.matmul(tau_matrix, self.B_matrix)))
121
121
 
122
- LWR_matrix = np.linalg.inv(LWR_matrix)
122
+ LWR_matrix = _radiosity_inverse_(LWR_matrix)
123
123
  aux_matrix = np.matmul(area_matrix, np.matmul(alpha_matrix,LWR_matrix))
124
124
 
125
125
  ig_dist_matrix = np.zeros((self._n_surfaces, self._n_spaces))
@@ -508,4 +508,18 @@ class Building(Component):
508
508
 
509
509
  # _______________ post_iteration _______________
510
510
  def post_iteration(self, time_index, date, daylight_saving, converged):
511
- super().post_iteration(time_index, date, daylight_saving, converged)
511
+ super().post_iteration(time_index, date, daylight_saving, converged)
512
+
513
+
514
+ def _radiosity_inverse_(matrix):
515
+ """Inverse of a radiosity matrix (I - F·rho - F·tau·B).
516
+
517
+ In a closed enclosure whose surfaces neither absorb nor transmit (rho = 1, e.g. surfaces
518
+ with solar absorptance or emissivity 0, as in the ASHRAE 140 ground-coupling cases) the
519
+ matrix is singular, since the rows of F sum to 1. Nothing is absorbed then, so any
520
+ generalised inverse gives the same (zero) absorbed radiation: use the pseudo-inverse.
521
+ """
522
+ try:
523
+ return np.linalg.inv(matrix)
524
+ except np.linalg.LinAlgError:
525
+ return np.linalg.pinv(matrix)
@@ -281,7 +281,10 @@ class Construction(Component):
281
281
  ox = []
282
282
  oy = []
283
283
  oz = []
284
- for i in range(1, n_coef):
284
+ # n_coef terms of the response series give n_coef valid a, b, c coefficients, as many
285
+ # as d has: with one fewer, the steady state (sum(a)/sum(d) = U) failed for light
286
+ # constructions with few roots, e.g. 6.59 instead of 5.05 W/m²K for a thin panel
287
+ for i in range(1, n_coef + 1):
285
288
  sum_exp_x = 0
286
289
  sum_exp_y = 0
287
290
  sum_exp_z = 0
@@ -304,10 +307,11 @@ class Construction(Component):
304
307
  if math.fabs(d[i]) < min_coef:
305
308
  d = d[0: i+1]
306
309
  break
307
- # Cut number of coeficients, se producen coeficientes extraños para muros ligeros a partir del n. de roots
308
- a = np.resize(a, n_coef-1)
309
- b = np.resize(b, n_coef-1)
310
- c = np.resize(c, n_coef-1)
310
+ # Only the first n_coef terms of the convolutions are valid (the response series
311
+ # above are truncated after n_coef terms)
312
+ a = np.resize(a, n_coef)
313
+ b = np.resize(b, n_coef)
314
+ c = np.resize(c, n_coef)
311
315
  # Cut a, b, c coefficients
312
316
  for i in range(len(a)):
313
317
  if (
@@ -68,6 +68,7 @@ class File_met(Component):
68
68
  self.pressure = np.zeros(8760)
69
69
  self.total_cloud_cover = np.zeros(8760)
70
70
  self.opaque_cloud_cover = np.zeros(8760)
71
+ self._delta_t = 3600 # time step, set by pre_simulation
71
72
 
72
73
  def check(self):
73
74
  errors = super().check()
@@ -219,6 +220,7 @@ class File_met(Component):
219
220
 
220
221
  def pre_simulation(self, n_time_steps, delta_t):
221
222
  super().pre_simulation(n_time_steps, delta_t)
223
+ self._delta_t = delta_t
222
224
 
223
225
  def pre_iteration(self, time_index, date, daylight_saving):
224
226
  super().pre_iteration(time_index, date, daylight_saving)
@@ -262,6 +264,8 @@ class File_met(Component):
262
264
  time_index, i, j, f)
263
265
  self._interpolate("opaque_cloud_cover", self.opaque_cloud_cover,
264
266
  time_index, i, j, f)
267
+ if self._sub_hourly_integrated_radiation_():
268
+ self._set_sub_hourly_radiation_(time_index, date, alt)
265
269
  # Corregir la directa si el sol no ha salido, y con alturas solares pequeñas
266
270
  if (alt < 3 and self.variable("sol_direct").values[time_index] > 0):
267
271
  self.variable(
@@ -282,6 +286,37 @@ class File_met(Component):
282
286
  self.variable("wet_bulb_temp").values[time_index] = sicro.GetTWetBulbFromRelHum(
283
287
  T, HR, p)
284
288
 
289
+ def _sub_hourly_integrated_radiation_(self):
290
+ """TMY3, TMY2 and WYEC2 radiation values are totals over the hour before each record."""
291
+ return (
292
+ self.parameter("file_type").value in ("TMY3", "TMY2", "WYEC2")
293
+ and self._delta_t < 3600
294
+ and 3600 % self._delta_t == 0
295
+ )
296
+
297
+ def _set_sub_hourly_radiation_(self, time_index, date, alt):
298
+ """Radiation of a sub-hourly time step that keeps the hour total of the file.
299
+
300
+ Interpolating linearly between hour centres would spread a quarter of each hour's
301
+ radiation over the neighbouring hours. Instead, the diffuse radiation is held through
302
+ the hour and the horizontal direct radiation follows the sine of the solar altitude,
303
+ scaled so that its mean over the time steps of the hour is the file value.
304
+ """
305
+ hour_start = date.replace(minute=0, second=0, microsecond=0)
306
+ k = int((hour_start - dt.datetime(date.year, 1, 1)).total_seconds() // 3600) % 8760
307
+ n = round(3600 / self._delta_t)
308
+ sin_alt = [
309
+ max(math.sin(math.radians(self.sunpos(hour_start + dt.timedelta(seconds=(m + 0.5) * self._delta_t))[1])), 0)
310
+ for m in range(n)
311
+ ]
312
+ mean_sin_alt = sum(sin_alt) / n
313
+ if mean_sin_alt > 0:
314
+ direct = self.sol_direct[k] * max(math.sin(math.radians(alt)), 0) / mean_sin_alt
315
+ else:
316
+ direct = 0
317
+ self.variable("sol_direct").values[time_index] = direct
318
+ self.variable("sol_diffuse").values[time_index] = self.sol_diffuse[k]
319
+
285
320
  def _interpolate(self, variable, array, time_i, i, j, f):
286
321
  self.variable(
287
322
  variable).values[time_i] = array[i] * (1 - f) + array[j] * f
@@ -377,66 +412,53 @@ class File_met(Component):
377
412
  return 0
378
413
  model = self.parameter("tilted_diffuse_model").value
379
414
  beta = math.radians(surf_altitude)
380
- if (model == "ISOTROPIC"):
381
- # Isotropic diffuse radiation (Liu-Jordan model)
382
- E_dif = sol_diffuse * (1 + math.sin(beta))/2
383
- elif (model == "HAY-DAVIES"):
384
- theta = self.solar_surface_angle(
385
- time_index, surf_azimuth, surf_altitude)
386
- if theta is not None:
387
- sol_direct = self.variable("sol_direct").values[time_index]
388
- sol_altitude = math.radians(self.variable(
389
- "sol_altitude").values[time_index])
390
- A = sol_direct/math.sin(sol_altitude)/1353
391
- R_b = math.cos(theta) / math.sin(sol_altitude)
392
- E_dif = sol_diffuse * A * R_b + sol_diffuse * \
393
- (1-A) * (1 + math.sin(beta))/2
394
- else:
395
- E_dif = sol_diffuse * (1 + math.sin(beta))/2
396
- elif (model == "REINDL"):
397
- theta = self.solar_surface_angle(
398
- time_index, surf_azimuth, surf_altitude)
399
- sol_altitude = math.radians(self.variable(
400
- "sol_altitude").values[time_index])
401
- sol_direct = self.variable("sol_direct").values[time_index]
402
- zenit = math.pi/2 - beta
403
- f_horizon = 1 + \
404
- math.sqrt(sol_direct/(sol_direct+sol_diffuse)) * \
405
- (math.sin(zenit/2))**3
406
- if theta is not None:
407
- A = sol_direct/math.sin(sol_altitude)/1353
408
- R_b = math.cos(theta) / math.sin(sol_altitude)
409
- E_dif = sol_diffuse * A * R_b + sol_diffuse * \
410
- (1-A) * (1 + math.sin(beta))/2 * f_horizon
411
- else:
412
- E_dif = sol_diffuse * (1 + math.sin(beta))/2 * f_horizon
413
- elif (model == "PEREZ"):
414
- theta = self.solar_surface_angle(
415
- time_index, surf_azimuth, surf_altitude)
416
- if theta is None: # No direct sun
417
- E_dif = sol_diffuse * (1 + math.sin(beta))/2
418
- else:
419
- sol_altitude = math.radians(self.variable(
420
- "sol_altitude").values[time_index])
421
- sol_direct = self.variable("sol_direct").values[time_index]
422
- zenit = math.pi/2 - beta
423
- sol_zenit = math.pi/2 - sol_altitude
424
- epsilon = ((sol_direct/math.sin(sol_altitude)+sol_diffuse) /
425
- sol_diffuse + 1.041 * sol_zenit**3)/(1 + 1.041*sol_zenit**3)
426
- m = 1 / math.cos(sol_zenit)
427
- Delta = m*sol_diffuse/1353
428
- f = self._perez_coef(epsilon)
429
- F_2 = f[3]+f[4]*Delta+f[5]*sol_zenit
430
- a = math.cos(theta)
431
- b = max(0.087156, math.cos(sol_zenit))
432
- F_1 = max(0, f[0]+f[1]*Delta+f[2]*sol_zenit)
433
- E_dif = sol_diffuse * \
434
- ((1-F_1)*(1+math.cos(zenit))/2 +
435
- F_1 * a/b + F_2 * math.sin(zenit))
436
- return E_dif
415
+ isotropic = sol_diffuse * (1 + math.sin(beta)) / 2
416
+ if model == "ISOTROPIC": # Liu-Jordan model
417
+ return isotropic
418
+ # Anisotropic models need the sun above the horizon (sol_altitude is NaN below 3°)
419
+ alt_sol = self.variable("sol_altitude").values[time_index]
420
+ if math.isnan(alt_sol):
421
+ return isotropic
422
+ sol_altitude = math.radians(alt_sol)
423
+ sol_direct = self.variable("sol_direct").values[time_index]
424
+ # Circumsolar term only on surfaces that see the sun (cos_theta clipped at 0), but the
425
+ # sky part keeps its anisotropy weighting also on surfaces facing away from the sun:
426
+ # using the isotropic model for them overestimated their diffuse radiation
427
+ cos_theta = max(0.0, self._cos_incidence(time_index, surf_azimuth, surf_altitude))
428
+ if model in ("HAY-DAVIES", "REINDL"):
429
+ A = sol_direct / math.sin(sol_altitude) / 1353 # anisotropy index
430
+ R_b = cos_theta / math.sin(sol_altitude)
431
+ f_horizon = 1
432
+ if model == "REINDL":
433
+ zenit = math.pi / 2 - beta
434
+ f_horizon = 1 + math.sqrt(sol_direct / (sol_direct + sol_diffuse)) * (math.sin(zenit / 2)) ** 3
435
+ return sol_diffuse * A * R_b + sol_diffuse * (1 - A) * (1 + math.sin(beta)) / 2 * f_horizon
436
+ # PEREZ
437
+ zenit = math.pi / 2 - beta
438
+ sol_zenit = math.pi / 2 - sol_altitude
439
+ epsilon = ((sol_direct / math.sin(sol_altitude) + sol_diffuse) / sol_diffuse
440
+ + 1.041 * sol_zenit**3) / (1 + 1.041 * sol_zenit**3)
441
+ m = 1 / math.cos(sol_zenit)
442
+ Delta = m * sol_diffuse / 1353
443
+ f = self._perez_coef(epsilon)
444
+ F_1 = max(0, f[0] + f[1] * Delta + f[2] * sol_zenit)
445
+ F_2 = f[3] + f[4] * Delta + f[5] * sol_zenit
446
+ b = max(0.087156, math.cos(sol_zenit))
447
+ return sol_diffuse * ((1 - F_1) * (1 + math.cos(zenit)) / 2 + F_1 * cos_theta / b + F_2 * math.sin(zenit))
448
+
449
+ def _cos_incidence(self, time_index, surf_azimuth, surf_altitude):
450
+ """Cosine of the angle between the sun and the surface exterior normal (negative when
451
+ the sun is behind the surface)."""
452
+ azi_sur = math.radians(surf_azimuth)
453
+ alt_sur = math.radians(surf_altitude)
454
+ azi_sol = math.radians(self.variable("sol_azimuth").values[time_index])
455
+ alt_sol = math.radians(self.variable("sol_altitude").values[time_index])
456
+ return (math.cos(azi_sol) * math.cos(alt_sol) * math.cos(azi_sur) * math.cos(alt_sur)
457
+ + math.sin(azi_sol) * math.cos(alt_sol) * math.sin(azi_sur) * math.cos(alt_sur)
458
+ + math.sin(alt_sol) * math.sin(alt_sur))
437
459
 
438
460
  def _perez_coef(self, epsilon):
439
- if epsilon > 1 and epsilon <= 1.065:
461
+ if epsilon <= 1.065: # overcast bin, also epsilon = 1 (no beam radiation)
440
462
  return [-0.0083117, 0.5877285, -0.0620636, -0.0596012, 0.0721249, -0.0220216]
441
463
  elif epsilon > 1.065 and epsilon <= 1.23:
442
464
  return [0.1299457, 0.6825954, -0.1513752, -0.0189325, 0.0659650, -0.0288748]
@@ -383,14 +383,14 @@ class HVAC_SZW_system(Component): # HVAC Single Zone Water system
383
383
  self.M_w = 0
384
384
  self.w_CA = self.w_MA
385
385
  return
386
- else:
387
- capacity, self.epsilon = self.h_coil.get_heating_capacity(
388
- self.T_MA,
389
- self.T_MAwb,
390
- heating_Tw,
391
- self.air_flow,
392
- self.heating_water_flow,
393
- )
386
+ # Coil capacity with the water temperature of either source
387
+ capacity, self.epsilon = self.h_coil.get_heating_capacity(
388
+ self.T_MA,
389
+ self.T_MAwb,
390
+ heating_Tw,
391
+ self.air_flow,
392
+ self.heating_water_flow,
393
+ )
394
394
  self.M_w = 0 # No latent load in heating
395
395
  self.w_CA = self.w_MA
396
396
  # Q_required is only the coil capacity
@@ -435,20 +435,20 @@ class HVAC_SZW_system(Component): # HVAC Single Zone Water system
435
435
  self.M_w = 0
436
436
  self.w_CA = self.w_MA
437
437
  return
438
- else:
439
- (
440
- capacity_sen,
441
- capacity_lat,
442
- self.T_ADP,
443
- self.epsilon,
444
- self.epsilon_adp,
445
- ) = self.c_coil.get_cooling_capacity(
446
- self.T_MA,
447
- self.T_MAwb,
448
- cooling_Tw,
449
- self.air_flow,
450
- self.cooling_water_flow,
451
- )
438
+ # Coil capacity with the water temperature of either source
439
+ (
440
+ capacity_sen,
441
+ capacity_lat,
442
+ self.T_ADP,
443
+ self.epsilon,
444
+ self.epsilon_adp,
445
+ ) = self.c_coil.get_cooling_capacity(
446
+ self.T_MA,
447
+ self.T_MAwb,
448
+ cooling_Tw,
449
+ self.air_flow,
450
+ self.cooling_water_flow,
451
+ )
452
452
 
453
453
  Q_required = -self.Q_required
454
454
  # Q_required is only the coil capacity
@@ -30,7 +30,7 @@ class Space(Component):
30
30
  self.add_parameter(Parameter_float("volume", 1, "m³", min=0.0))
31
31
  self.add_parameter(Parameter_float("furniture_weight", 10, "kg/m²", min=0.0))
32
32
  self.add_parameter(Parameter_float("convergence_DT", 0.01, "°C", min=0.0))
33
- self.add_parameter(Parameter_float("convergence_Dw", 0.1, "g/kg", min=0.0))
33
+ self.add_parameter(Parameter_float("convergence_Dw", 0.01, "g/kg", min=0.0))
34
34
 
35
35
  # Variables
36
36
  self.add_variable(Variable("temperature", unit="°C"))
@@ -504,6 +504,9 @@ class Space(Component):
504
504
  self._calculate_heat_fluxes(time_index)
505
505
 
506
506
  def _calculate_heat_fluxes(self, time_i):
507
+ # The uncontrolled systems may have changed since the last thermal solution (e.g. a VAV
508
+ # system replaces its maximum air flow by a controlled flow): report the current ones
509
+ self._acumulate_u_systems()
507
510
  V_inf = self.variable("infiltration_flow").values[time_i]
508
511
  T_ext = self._file_met.variable("temperature").values[time_i]
509
512
  w_ext = self._file_met.variable("abs_humidity").values[time_i]
@@ -2,6 +2,7 @@ import time
2
2
  import numpy as np
3
3
  import math
4
4
  from scipy.interpolate import RegularGridInterpolator
5
+ from scipy.ndimage import distance_transform_edt
5
6
  import matplotlib.pyplot as plt
6
7
  from opensimula.visual_3D.Polygon_3D import Polygon_3D
7
8
 
@@ -424,15 +425,31 @@ class Environment_3D:
424
425
  k = k + 1
425
426
  j = j + 1
426
427
 
428
+ # Interpolation tables. Where the sun is behind a polygon its sunny fraction drops to 0,
429
+ # but there the direct radiation is already 0, so those cells take the value of the
430
+ # nearest cell with the sun in front: otherwise the interpolation between both sides
431
+ # of that step gives wrong fractions near grazing sun (e.g. 0.84-1.11 for an unshaded
432
+ # roof with cubic splines). Linear interpolation keeps the values within [0, 1], and
433
+ # the azimuth grid is closed at 360° so that 350°-360° is interpolated, not extrapolated.
434
+ azimuth_grid = np.append(self.shadow_azimuth_grid, 360)
427
435
  self.sunny_interpolation_functions = []
428
- for i in range(0, len(sunny_polygons)):
436
+ for i, polygon in enumerate(sunny_polygons):
437
+ table = self.sunny_fraction_tables[i].copy()
438
+ behind = np.array(
439
+ [[polygon.get_angle_with_normal(azi, alt) >= math.pi / 2
440
+ for alt in self.shadow_altitude_grid] for azi in self.shadow_azimuth_grid]
441
+ )
442
+ if behind.any() and not behind.all():
443
+ nearest = distance_transform_edt(behind, return_distances=False, return_indices=True)
444
+ table = table[nearest[0], nearest[1]]
445
+ table = np.vstack([table, table[0]])
429
446
  self.sunny_interpolation_functions.append(
430
447
  RegularGridInterpolator(
431
- (self.shadow_azimuth_grid, self.shadow_altitude_grid),
432
- self.sunny_fraction_tables[i],
448
+ (azimuth_grid, self.shadow_altitude_grid),
449
+ table,
433
450
  bounds_error=False,
434
451
  fill_value=None,
435
- method="cubic",
452
+ method="linear",
436
453
  )
437
454
  )
438
455
 
@@ -471,7 +488,8 @@ class Environment_3D:
471
488
  if self.solar_tables_calculated == True:
472
489
  if azi < 0:
473
490
  azi = azi + 360
474
- return self.sunny_interpolation_functions[sunny_i]((azi, alt))
491
+ # clip: linear extrapolation beyond the ±85° altitude grid could leave [0, 1]
492
+ return float(np.clip(self.sunny_interpolation_functions[sunny_i]((azi, alt)), 0, 1))
475
493
  else:
476
494
  return 1
477
495
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: opensimula
3
- Version: 0.8.7
3
+ Version: 0.8.8
4
4
  Summary: Component-based time simulation environment in Python
5
5
  Author-email: "Juan F. Coronel" <jfc@us.es>
6
6
  Requires-Python: >=3.12
@@ -24,8 +24,10 @@ Dynamic: license-file
24
24
 
25
25
  # OpenSimula
26
26
 
27
+ [![Tests](https://github.com/jfCoronel/OpenSimula/actions/workflows/tests.yml/badge.svg)](https://github.com/jfCoronel/OpenSimula/actions/workflows/tests.yml)
27
28
  [![PyPI version](https://img.shields.io/pypi/v/opensimula)](https://pypi.org/project/opensimula/)
28
29
  [![Python versions](https://img.shields.io/pypi/pyversions/opensimula)](https://pypi.org/project/opensimula/)
30
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.23210985.svg)](https://doi.org/10.5281/zenodo.23210985)
29
31
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
30
32
 
31
33
  **OpenSimula** is a component-based time simulation environment in Python, focused on the
@@ -162,6 +164,21 @@ reference, is at [jfcoronel.github.io/OpenSimula](https://jfcoronel.github.io/Op
162
164
  Bugs and feature requests are tracked on
163
165
  [GitHub Issues](https://github.com/jfCoronel/OpenSimula/issues).
164
166
 
167
+ ## Contributing
168
+
169
+ Contributions are welcome: bug reports, fixes, new components, validation cases and examples.
170
+ See [CONTRIBUTING.md](https://github.com/jfCoronel/OpenSimula/blob/main/CONTRIBUTING.md) for how to set up a development environment, run the
171
+ tests and submit a pull request.
172
+
173
+ ## Citing OpenSimula
174
+
175
+ If you use OpenSimula in your research, please cite it with its DOI,
176
+ [10.5281/zenodo.23210985](https://doi.org/10.5281/zenodo.23210985), which always resolves to
177
+ the latest version (each version also has its own DOI, listed on
178
+ [Zenodo](https://doi.org/10.5281/zenodo.23210985)). The full metadata is in
179
+ [CITATION.cff](https://github.com/jfCoronel/OpenSimula/blob/main/CITATION.cff) (GitHub's "Cite this repository" button generates APA and BibTeX
180
+ entries from it).
181
+
165
182
  ## Main Developers
166
183
 
167
184
  * [Juan F. Coronel](http://jfc.us.es), Universidad de Sevilla
@@ -319,6 +319,6 @@ def test_ff_temperature():
319
319
 
320
320
  T = pro.component("spaces_1").variable("temperature").values
321
321
 
322
- assert T.mean() == pytest.approx(25.175713235125627)
323
- assert T.max() == pytest.approx(65.7183978047975)
324
- assert T.min() == pytest.approx(-13.37619837019802)
322
+ assert T.mean() == pytest.approx(25.07681213119194)
323
+ assert T.max() == pytest.approx(65.66725801535114)
324
+ assert T.min() == pytest.approx(-13.387879316775047)
@@ -349,7 +349,23 @@ def test_HVAC_perfect_system():
349
349
  peak_heating = load.max()/1000
350
350
  peak_cooling = -load.min()/1000
351
351
 
352
- assert annual_heating == pytest.approx(4.6204817991668445)
353
- assert annual_cooling == pytest.approx(4.177237252213477)
354
- assert peak_heating == pytest.approx(3.257835742527949)
355
- assert peak_cooling == pytest.approx(5.447455230231858)
352
+ assert annual_heating == pytest.approx(4.635639661707876)
353
+ assert annual_cooling == pytest.approx(4.135999834242331)
354
+ assert peak_heating == pytest.approx(3.2579149609779443)
355
+ assert peak_cooling == pytest.approx(5.451313222438749)
356
+
357
+
358
+ def test_interpolated_sunny_fraction():
359
+ # Unshaded surfaces must get a sunny fraction of exactly 1 with INTERPOLATION, also with
360
+ # low sun, and every fraction must stay within [0, 1] (cubic interpolation overshot both)
361
+ sim = osim.Simulation()
362
+ pro = sim.new_project("pro")
363
+ pro.read_dict({**case610_dict, "shadow_calculation": "INTERPOLATION", "n_time_steps": 24})
364
+ pro.simulate()
365
+ env = pro.env_3D
366
+ roof = env.get_sunny_index("roof_wall")
367
+ window = env.get_sunny_index("south_window_1")
368
+ for azi in range(-180, 181, 7):
369
+ for alt in [1, 3, 5, 8, 12, 20, 40, 70, 88]:
370
+ assert env.get_direct_interpolated_sunny_fraction(roof, azi, alt) == pytest.approx(1)
371
+ assert 0 <= env.get_direct_interpolated_sunny_fraction(window, azi, alt) <= 1
@@ -74,6 +74,29 @@ project_dic = {
74
74
  ],
75
75
  "thicknesses": [0.016, 0.076, 0.203, 0.076, 0.025],
76
76
  },
77
+ {
78
+ "type": "Material",
79
+ "name": "Opaque panel",
80
+ "conductivity": 1,
81
+ "density": 2470,
82
+ "specific_heat": 750,
83
+ },
84
+ {
85
+ "type": "Material",
86
+ "name": "Air gap",
87
+ "conductivity": 0.0625,
88
+ "density": 1.2925,
89
+ "specific_heat": 1006,
90
+ },
91
+ {
92
+ # Thin and light, with a single root: its CTF used to fail the steady state
93
+ # (ASHRAE 140 high-conductance wall)
94
+ "type": "Construction",
95
+ "name": "Thin panel",
96
+ "solar_alpha": [0.8, 0.8],
97
+ "materials": ["Opaque panel", "Air gap", "Opaque panel"],
98
+ "thicknesses": [0.003048, 0.012, 0.003048],
99
+ },
77
100
  ],
78
101
  }
79
102
 
@@ -102,6 +125,12 @@ def test_walls_1h():
102
125
  U = sum(wall._coef_T_a)/sum(wall._coef_Q)
103
126
  assert 1/wall.thermal_resistance() == pytest.approx(U, 0.00001)
104
127
 
128
+ wall = pro.component("Thin panel")
129
+ assert sum(wall._coef_T_a) == pytest.approx(sum(wall._coef_T_b), 0.00001)
130
+ assert sum(wall._coef_T_a) == pytest.approx(sum(wall._coef_T_c), 0.00001)
131
+ U = sum(wall._coef_T_a)/sum(wall._coef_Q)
132
+ assert 1/wall.thermal_resistance() == pytest.approx(U, 0.00001)
133
+
105
134
 
106
135
  def test_walls_15min():
107
136
  sim = osim.Simulation()
@@ -127,3 +156,9 @@ def test_walls_15min():
127
156
  assert sum(wall._coef_T_a) == pytest.approx(sum(wall._coef_T_c), 0.00001)
128
157
  U = sum(wall._coef_T_a)/sum(wall._coef_Q)
129
158
  assert 1/wall.thermal_resistance() == pytest.approx(U, 0.00001)
159
+
160
+ wall = pro.component("Thin panel")
161
+ assert sum(wall._coef_T_a) == pytest.approx(sum(wall._coef_T_b), 0.00001)
162
+ assert sum(wall._coef_T_a) == pytest.approx(sum(wall._coef_T_c), 0.00001)
163
+ U = sum(wall._coef_T_a)/sum(wall._coef_Q)
164
+ assert 1/wall.thermal_resistance() == pytest.approx(U, 0.00001)
@@ -131,3 +131,22 @@ def test_ground_temperature_kasuda():
131
131
  T_mean = met.T_average
132
132
  # At 10m, temperature should be very close to annual mean
133
133
  assert abs(var_z10.values[0] - T_mean) < 2.0 # Within 2°C of mean
134
+
135
+
136
+ def test_perez_surface_facing_away_from_sun():
137
+ # A surface that does not see the sun gets no circumsolar diffuse, but the rest of the
138
+ # sky radiation keeps the Perez (1 - F1) weighting, so it is below the isotropic value
139
+ # (the fallback to the isotropic model overestimated north walls by ~20 %)
140
+ sim = osim.Simulation()
141
+ pro = sim.new_project("pro")
142
+ pro.read_dict({**project, "simulation_file_met": "denver"})
143
+ pro.simulate()
144
+ met = pro.component("denver")
145
+ perez, isotropic = 0, 0
146
+ for i in range(8760):
147
+ cos_theta = met._cos_incidence(i, 180, 0) # north vertical wall
148
+ if met.variable("sol_direct").values[i] > 0 and cos_theta < 0:
149
+ perez += met.solar_diffuse_rad(i, 180, 0)
150
+ isotropic += met.variable("sol_diffuse").values[i] / 2
151
+ assert isotropic > 0
152
+ assert perez < 0.95 * isotropic
@@ -373,12 +373,12 @@ def test_HVAC_DX_system_without_vent():
373
373
  power = pro.component("system").variable("power").values.sum()/1e6
374
374
  annual_latent = pro.component("system").variable("Q_latent").values.sum()/1e6
375
375
 
376
- assert annual_heating == pytest.approx(3.650510994153891)
377
- assert annual_cooling == pytest.approx(5.000080590503151)
378
- assert peak_heating == pytest.approx(2.753531135235261)
379
- assert peak_cooling == pytest.approx(5.6938218907562925)
380
- assert power == pytest.approx(14.614108682923018)
381
- assert annual_latent == pytest.approx(0.05590912058065888)
376
+ assert annual_heating == pytest.approx(3.662558442285695)
377
+ assert annual_cooling == pytest.approx(4.953843972941708)
378
+ assert peak_heating == pytest.approx(2.753548603988852)
379
+ assert peak_cooling == pytest.approx(5.697679866477179)
380
+ assert power == pytest.approx(14.602619928128332)
381
+ assert annual_latent == pytest.approx(0.05448078004799185)
382
382
 
383
383
 
384
384
  def test_HVAC_DX_system_with_vent():
@@ -400,10 +400,10 @@ def test_HVAC_DX_system_with_vent():
400
400
  annual_latent = pro.component("system").variable("Q_latent").values.sum()/1e6
401
401
  print(annual_heating,annual_cooling,peak_heating,peak_cooling,power,annual_latent)
402
402
 
403
- assert annual_heating == pytest.approx(6.56738727816441)
404
- assert annual_cooling == pytest.approx(4.086059953886718)
405
- assert peak_heating == pytest.approx(4.013664255449629)
406
- assert peak_cooling == pytest.approx(5.374631379884803)
407
- assert power == pytest.approx(16.747003071596513)
408
- assert annual_latent == pytest.approx(0.07848460851539883)
403
+ assert annual_heating == pytest.approx(6.584015158948548)
404
+ assert annual_cooling == pytest.approx(4.047643778543491)
405
+ assert peak_heating == pytest.approx(4.013662511248714)
406
+ assert peak_cooling == pytest.approx(5.378485230257034)
407
+ assert power == pytest.approx(16.73810229504327)
408
+ assert annual_latent == pytest.approx(0.07588097994857204)
409
409
 
@@ -376,12 +376,12 @@ def test_HVAC_FAN_COIL_water_system():
376
376
  peak_heating = Q_sens.max() / 1000
377
377
  peak_cooling = -Q_sens.min() / 1000
378
378
 
379
- assert annual_heating == pytest.approx(0.0723509834896354, rel=1e-5)
380
- assert annual_cooling == pytest.approx(4.8761768134697965, rel=1e-5)
381
- assert annual_humidification == pytest.approx(0.5830451955419828, rel=1e-5)
379
+ assert annual_heating == pytest.approx(0.07271765233509604, rel=1e-5)
380
+ assert annual_cooling == pytest.approx(4.838342243586122, rel=1e-5)
381
+ assert annual_humidification == pytest.approx(0.5796872363278687, rel=1e-5)
382
382
  assert annual_dehumidification == pytest.approx(0.0)
383
- assert peak_heating == pytest.approx(2.6769614390992165, rel=1e-5)
384
- assert peak_cooling == pytest.approx(5.530450229446886, rel=1e-5)
383
+ assert peak_heating == pytest.approx(2.679327322280834, rel=1e-5)
384
+ assert peak_cooling == pytest.approx(5.531297059385839, rel=1e-5)
385
385
 
386
386
  yearly_df = water.variable_dataframe(
387
387
  frequency="yearly", value="sum", pos_neg_columns=["Q_gen"]
@@ -389,13 +389,13 @@ def test_HVAC_FAN_COIL_water_system():
389
389
  heating_generation = yearly_df["Q_gen_pos"].values[0] / 1e6
390
390
  cooling_generation = yearly_df["Q_gen_neg"].values[0] / 1e6
391
391
 
392
- assert heating_generation == pytest.approx(0.07351703946829136, rel=1e-5)
393
- assert cooling_generation == pytest.approx(-4.969237804755222, rel=1e-5)
392
+ assert heating_generation == pytest.approx(0.07388369713417615, rel=1e-5)
393
+ assert cooling_generation == pytest.approx(-4.93133992091134, rel=1e-5)
394
394
 
395
395
  T_WGO = water.variable("T_WGO").values
396
396
  T_WGI = water.variable("T_WGI").values
397
397
 
398
- assert T_WGO.max() == pytest.approx(51.407879045644584, rel=1e-5)
398
+ assert T_WGO.max() == pytest.approx(50.6756558449134, rel=1e-5)
399
399
  assert T_WGO.min() == pytest.approx(7.0)
400
- assert T_WGI.max() == pytest.approx(51.42244343580782, rel=1e-5)
401
- assert T_WGI.min() == pytest.approx(7.042183201358537, rel=1e-5)
400
+ assert T_WGI.max() == pytest.approx(50.688432226123474, rel=1e-5)
401
+ assert T_WGI.min() == pytest.approx(7.041926599097343, rel=1e-5)
@@ -0,0 +1,30 @@
1
+ import opensimula as osim
2
+ import math
3
+ import pytest
4
+
5
+ def test_Iterative_process():
6
+ def y(x):
7
+ return math.cos(x)
8
+ x_i = 0.5
9
+ itera = osim.Iterative_process(x_i)
10
+ while not itera.converged():
11
+ x_i = itera.estimate_next_x(y(x_i))
12
+
13
+ assert x_i == pytest.approx(0.73908,abs=1e-4)
14
+
15
+
16
+ def test_Iterative_process_residual():
17
+ # Slowly contracting fixed point (x = 0.95 x + 0.05, solution 1): at convergence the
18
+ # fixed-point residual must be below the tolerance. Testing the relaxed step instead
19
+ # stopped with residuals up to tol / lambda_min.
20
+ def y(x):
21
+ return 0.95 * x + 0.05
22
+ tol = 1e-4
23
+ x_i = 0.0
24
+ itera = osim.Iterative_process(x_i, tol=tol, n_ini_relax=3, rel_vel=0.8)
25
+ n = 0
26
+ while not itera.converged() and n < 10000:
27
+ x_i = itera.estimate_next_x(y(x_i))
28
+ n += 1
29
+ assert abs(y(x_i) - x_i) < tol
30
+ assert x_i == pytest.approx(1, abs=tol / (1 - 0.95))
@@ -124,15 +124,21 @@ def test_real_projects_validate(schema, relative_path):
124
124
  jsonschema.validate(document, schema)
125
125
 
126
126
 
127
- def _project_dicts_in_notebooks(directory):
128
- """Yield (notebook, project dict) for every project literal in the notebooks.
129
-
130
- The ASHRAE 140 cases define their projects as dict literals inside code
131
- cells, which makes them the largest corpus of real project definitions in
132
- the repository. They are read with ast.literal_eval, so cells that build a
133
- dict from variables are skipped rather than executed.
127
+ def _ashrae_140_projects(directory):
128
+ """Yield (name, project dict) for every ASHRAE 140 case.
129
+
130
+ The cases are the largest corpus of real project definitions in the
131
+ repository: project JSON files in <TEST>/cases/ for the automated folders,
132
+ plus dict literals still defined inside notebooks (folders not automated
133
+ yet; read with ast.literal_eval, so cells that build a dict from variables
134
+ are skipped rather than executed). Archived notebooks are skipped: their
135
+ cases are the JSON files.
134
136
  """
137
+ for json_path in sorted(directory.glob("*/cases/*.json")):
138
+ yield json_path.name, json.loads(json_path.read_text())
135
139
  for notebook_path in sorted(directory.rglob("*.ipynb")):
140
+ if "old_notebooks" in notebook_path.parts:
141
+ continue
136
142
  notebook = nbformat.read(notebook_path, as_version=4)
137
143
  for cell in notebook.cells:
138
144
  if cell.cell_type != "code":
@@ -160,7 +166,7 @@ def test_ashrae_140_projects_validate(schema):
160
166
  validator = jsonschema.Draft202012Validator(schema)
161
167
  failures = []
162
168
  n_documents = 0
163
- for notebook, document in _project_dicts_in_notebooks(directory):
169
+ for notebook, document in _ashrae_140_projects(directory):
164
170
  n_documents += 1
165
171
  for component in document["components"]:
166
172
  type_name = component.get("type")
@@ -176,7 +182,7 @@ def test_ashrae_140_projects_validate(schema):
176
182
  failures.append(f"{notebook}: {type_name}.{path}: {error.message}")
177
183
  validator.validate(document)
178
184
 
179
- assert n_documents > 0, "no project dicts found in the notebooks"
185
+ assert n_documents > 0, "no ASHRAE 140 project definitions found"
180
186
  assert failures == [], "\n".join(failures[:20])
181
187
 
182
188
 
@@ -1,47 +0,0 @@
1
- import math
2
-
3
- class Iterative_process:
4
- def __init__(self, x_0, tol = 1e-5, lambda_min=0.1, lambda_max=1.0, n_ini_relax=2,rel_vel = 0.9):
5
- """
6
- Iterative process using the fixed-point method with adaptive relaxation coefficient
7
-
8
- Parameters:
9
- x_0: Initial value
10
- tol: convergence tolerance
11
- lambda_min, lambda_max: relaxation coefficient range
12
- n_ini_relax: Number of iterations in which adaptive relaxation is initiated, must be greater than 1
13
- """
14
- self.x = [x_0]
15
- self.tol = tol
16
- self.lambda_min = lambda_min
17
- self.lambda_max = lambda_max
18
- self.lambda_i = lambda_max
19
- self.n_ini_relax = n_ini_relax
20
- self.rel_vel = rel_vel
21
-
22
-
23
- def converged(self):
24
- if len(self.x) == 1:
25
- return False
26
- else:
27
- if abs(self.x[-1]-self.x[-2]) < self.tol:
28
- return True
29
- else:
30
- return False
31
-
32
- def estimate_next_x(self, proposed_x):
33
- x_next = (1 - self.lambda_i) * self.x[-1] + self.lambda_i * proposed_x
34
-
35
- # Ajuste dinámico de lambda (reduce si hay oscilaciones)
36
- if len(self.x)>self.n_ini_relax:
37
- #if abs(x_next - self.x[-1]) > abs(self.x[-1]- self.x[-2]):
38
- self.lambda_i = max(self.lambda_min, self.lambda_i * self.rel_vel) # Reducimos lambda
39
- #else:
40
- #self.lambda_i = min(self.lambda_max, self.lambda_i * 1/self.rel_vel) # Aumentamos lambda
41
-
42
- self.x.append(x_next)
43
- return x_next
44
-
45
- def set_next_x(self, next_x):
46
- self.x.append(next_x)
47
-
@@ -1,13 +0,0 @@
1
- import opensimula as osim
2
- import math
3
- import pytest
4
-
5
- def test_Iterative_process():
6
- def y(x):
7
- return math.cos(x)
8
- x_i = 0.5
9
- itera = osim.Iterative_process(x_i)
10
- while not itera.converged():
11
- x_i = itera.estimate_next_x(y(x_i))
12
-
13
- assert x_i == pytest.approx(0.73908,abs=1e-4)
File without changes
File without changes