mabuseif commited on
Commit
e72e041
·
verified ·
1 Parent(s): 35baf23

Upload psychrometrics.py

Browse files
Files changed (1) hide show
  1. utils/psychrometrics.py +592 -398
utils/psychrometrics.py CHANGED
@@ -1,581 +1,775 @@
1
  """
2
  Psychrometric module for HVAC Load Calculator.
3
- This module implements psychrometric calculations for air properties.
 
4
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
 
 
 
 
5
  """
6
 
7
  from typing import Dict, List, Any, Optional, Tuple
8
  import math
9
  import numpy as np
 
 
 
 
10
 
11
- # Constants
12
- ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure in Pa
13
  WATER_MOLECULAR_WEIGHT = 18.01534 # kg/kmol
14
  DRY_AIR_MOLECULAR_WEIGHT = 28.9645 # kg/kmol
15
  UNIVERSAL_GAS_CONSTANT = 8314.462618 # J/(kmol·K)
16
- GAS_CONSTANT_DRY_AIR = UNIVERSAL_GAS_CONSTANT / DRY_AIR_MOLECULAR_WEIGHT # J/(kg·K)
17
- GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K)
18
 
 
 
 
 
19
 
20
  class Psychrometrics:
21
  """Class for psychrometric calculations."""
22
-
 
23
  @staticmethod
24
- def validate_inputs(t_db: float, rh: Optional[float] = None, p_atm: Optional[float] = None) -> None:
 
 
25
  """
26
  Validate input parameters for psychrometric calculations.
27
-
28
  Args:
29
  t_db: Dry-bulb temperature in °C
30
- rh: Relative humidity in % (0-100), optional
31
- p_atm: Atmospheric pressure in Pa, optional
32
-
 
33
  Raises:
34
  ValueError: If inputs are invalid
35
  """
36
- if not -50 <= t_db <= 60:
37
- raise ValueError(f"Temperature {t_db}°C must be between -50°C and 60°C")
38
  if rh is not None and not 0 <= rh <= 100:
39
- raise ValueError(f"Relative humidity {rh}% must be between 0 and 100%")
40
- if p_atm is not None and p_atm <= 0:
41
- raise ValueError(f"Atmospheric pressure {p_atm} Pa must be positive")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
 
 
 
43
  @staticmethod
44
  def saturation_pressure(t_db: float) -> float:
45
  """
46
  Calculate saturation pressure of water vapor.
47
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5 and 6.
48
-
49
  Args:
50
  t_db: Dry-bulb temperature in °C
51
-
52
  Returns:
53
  Saturation pressure in Pa
54
  """
55
- Psychrometrics.validate_inputs(t_db)
56
-
57
- # Convert temperature to Kelvin
58
  t_k = t_db + 273.15
59
-
60
- # ASHRAE Fundamentals 2017 Chapter 1, Equation 5 & 6
61
- if t_db >= 0:
62
- # Equation 5 for temperatures above freezing
63
- c1 = -5.8002206e3
64
- c2 = 1.3914993
65
- c3 = -4.8640239e-2
66
- c4 = 4.1764768e-5
67
- c5 = -1.4452093e-8
68
- c6 = 6.5459673
69
- else:
70
- # Equation 6 for temperatures below freezing
71
- c1 = -5.6745359e3
72
- c2 = 6.3925247
73
- c3 = -9.6778430e-3
74
- c4 = 6.2215701e-7
75
- c5 = 2.0747825e-9
76
- c6 = -9.4840240e-13
77
- c7 = 4.1635019
78
-
79
- # Calculate natural log of saturation pressure in Pa
80
  if t_db >= 0:
81
- ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * math.log(t_k)
 
 
 
 
 
 
 
82
  else:
83
- ln_p_ws = c1 / t_k + c2 + c3 * t_k + c4 * t_k**2 + c5 * t_k**3 + c6 * t_k**4 + c7 * math.log(t_k)
84
-
85
- # Convert from natural log to actual pressure in Pa
 
 
 
 
 
 
 
86
  p_ws = math.exp(ln_p_ws)
87
-
88
  return p_ws
89
-
90
  @staticmethod
91
  def humidity_ratio(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
92
  """
93
  Calculate humidity ratio (mass of water vapor per unit mass of dry air).
94
- Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20.
95
-
96
  Args:
97
  t_db: Dry-bulb temperature in °C
98
  rh: Relative humidity (0-100)
99
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
100
-
101
  Returns:
102
  Humidity ratio in kg water vapor / kg dry air
103
  """
104
- Psychrometrics.validate_inputs(t_db, rh, p_atm)
105
-
106
- # Convert relative humidity to decimal
107
- rh_decimal = rh / 100.0
108
-
109
- # Calculate saturation pressure
110
  p_ws = Psychrometrics.saturation_pressure(t_db)
111
-
112
- # Calculate partial pressure of water vapor
113
- p_w = rh_decimal * p_ws
114
-
115
  if p_w >= p_atm:
116
- raise ValueError("Partial pressure of water vapor exceeds atmospheric pressure")
117
-
118
- # Calculate humidity ratio
 
 
 
 
 
 
119
  w = 0.621945 * p_w / (p_atm - p_w)
120
-
121
- return w
122
-
123
  @staticmethod
124
  def relative_humidity(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
125
  """
126
  Calculate relative humidity from humidity ratio.
127
- Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20 (rearranged).
128
-
129
  Args:
130
  t_db: Dry-bulb temperature in °C
131
  w: Humidity ratio in kg water vapor / kg dry air
132
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
133
-
134
  Returns:
135
  Relative humidity (0-100)
136
  """
137
- Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
138
- if w < 0:
139
- raise ValueError("Humidity ratio cannot be negative")
140
-
141
- # Calculate saturation pressure
142
  p_ws = Psychrometrics.saturation_pressure(t_db)
143
-
144
- # Calculate partial pressure of water vapor
145
  p_w = p_atm * w / (0.621945 + w)
146
-
147
- # Calculate relative humidity
 
 
 
 
148
  rh = 100.0 * p_w / p_ws
149
-
150
- return rh
151
-
152
  @staticmethod
153
- def wet_bulb_temperature(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
 
154
  """
155
- Calculate wet-bulb temperature using iterative method.
156
- Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 35.
157
-
 
158
  Args:
159
  t_db: Dry-bulb temperature in °C
160
- rh: Relative humidity (0-100)
 
161
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
162
-
163
  Returns:
164
  Wet-bulb temperature in °C
165
  """
166
- Psychrometrics.validate_inputs(t_db, rh, p_atm)
167
-
168
- # Calculate humidity ratio at given conditions
169
- w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
170
-
171
- # Initial guess for wet-bulb temperature
172
- t_wb = t_db
173
-
174
- # Iterative solution
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  max_iterations = 100
176
- tolerance = 0.001 # °C
177
-
178
  for i in range(max_iterations):
179
- # Validate wet-bulb temperature
180
- Psychrometrics.validate_inputs(t_wb)
181
-
182
- # Calculate saturation pressure at wet-bulb temperature
183
  p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
184
-
185
- # Calculate saturation humidity ratio at wet-bulb temperature
186
  w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
187
-
188
- # Calculate humidity ratio from wet-bulb temperature
189
- h_fg = 2501000 + 1840 * t_wb # Latent heat of vaporization at t_wb in J/kg
190
- c_pa = 1006 # Specific heat of dry air in J/(kg·K)
191
- c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
192
-
193
- w_calc = ((h_fg - c_pw * (t_db - t_wb)) * w_s_wb - c_pa * (t_db - t_wb)) / (h_fg + c_pw * t_db - c_pw * t_wb)
194
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  # Check convergence
196
- if abs(w - w_calc) < tolerance:
197
  break
198
-
199
- # Adjust wet-bulb temperature
200
- if w_calc > w:
201
- t_wb -= 0.1
 
 
 
 
 
202
  else:
203
- t_wb += 0.1
204
-
205
- return t_wb
206
-
 
 
 
 
 
 
 
 
207
  @staticmethod
208
- def dew_point_temperature(t_db: float, rh: float) -> float:
 
209
  """
210
  Calculate dew point temperature.
211
- Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 39 and 40.
212
-
213
  Args:
214
- t_db: Dry-bulb temperature in °C
215
- rh: Relative humidity (0-100)
216
-
 
217
  Returns:
218
  Dew point temperature in °C
219
  """
220
- Psychrometrics.validate_inputs(t_db, rh)
221
-
222
- # Convert relative humidity to decimal
223
- rh_decimal = rh / 100.0
224
-
225
- # Calculate saturation pressure
226
- p_ws = Psychrometrics.saturation_pressure(t_db)
227
-
228
- # Calculate partial pressure of water vapor
229
- p_w = rh_decimal * p_ws
230
-
231
- # Calculate dew point temperature
232
- alpha = math.log(p_w / 1000.0) # Convert to kPa for the formula
233
-
234
- if t_db >= 0:
235
- # For temperatures above freezing
236
- c14 = 6.54
237
- c15 = 14.526
238
- c16 = 0.7389
239
- c17 = 0.09486
240
- c18 = 0.4569
241
-
242
- t_dp = c14 + c15 * alpha + c16 * alpha**2 + c17 * alpha**3 + c18 * p_w**(0.1984)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  else:
244
- # For temperatures below freezing
245
- c14 = 6.09
246
- c15 = 12.608
247
- c16 = 0.4959
248
-
249
- t_dp = c14 + c15 * alpha + c16 * alpha**2
250
-
251
- return t_dp
252
-
 
 
 
 
 
 
 
 
 
253
  @staticmethod
254
  def enthalpy(t_db: float, w: float) -> float:
255
  """
256
  Calculate specific enthalpy of moist air.
257
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30.
258
-
259
  Args:
260
  t_db: Dry-bulb temperature in °C
261
  w: Humidity ratio in kg water vapor / kg dry air
262
-
263
  Returns:
264
  Specific enthalpy in J/kg dry air
265
  """
266
- Psychrometrics.validate_inputs(t_db)
267
- if w < 0:
268
- raise ValueError("Humidity ratio cannot be negative")
269
-
270
- c_pa = 1006 # Specific heat of dry air in J/(kg·K)
271
- h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
272
- c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
273
-
274
- h = c_pa * t_db + w * (h_fg + c_pw * t_db)
275
-
276
  return h
277
-
278
  @staticmethod
279
  def specific_volume(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
280
  """
281
  Calculate specific volume of moist air.
282
- Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 28.
283
-
284
  Args:
285
  t_db: Dry-bulb temperature in °C
286
  w: Humidity ratio in kg water vapor / kg dry air
287
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
288
-
289
  Returns:
290
  Specific volume in m³/kg dry air
291
  """
292
- Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
293
- if w < 0:
294
- raise ValueError("Humidity ratio cannot be negative")
295
-
296
- # Convert temperature to Kelvin
297
  t_k = t_db + 273.15
298
-
299
- r_da = GAS_CONSTANT_DRY_AIR # Gas constant for dry air in J/(kg·K)
300
-
301
- v = r_da * t_k * (1 + 1.607858 * w) / p_atm
302
-
303
  return v
304
-
305
  @staticmethod
306
  def density(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
307
  """
308
  Calculate density of moist air.
309
- Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, derived from Equation 28.
310
-
 
311
  Args:
312
  t_db: Dry-bulb temperature in °C
313
  w: Humidity ratio in kg water vapor / kg dry air
314
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
315
-
316
  Returns:
317
- Density in kg/m³
318
- """
319
- Psychrometrics.validate_inputs(t_db, p_atm=p_atm)
320
- if w < 0:
321
- raise ValueError("Humidity ratio cannot be negative")
322
-
323
- # Calculate specific volume
324
- v = Psychrometrics.specific_volume(t_db, w, p_atm)
325
-
326
- # Density is the reciprocal of specific volume
327
  rho = (1 + w) / v
328
-
329
  return rho
330
-
 
331
  @staticmethod
332
- def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
 
333
  """
334
  Calculate all psychrometric properties of moist air.
335
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
336
-
337
  Args:
338
  t_db: Dry-bulb temperature in °C
339
  rh: Relative humidity (0-100)
340
- p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
341
-
 
342
  Returns:
343
- Dictionary with all psychrometric properties
344
- """
345
- Psychrometrics.validate_inputs(t_db, rh, p_atm)
346
-
347
- # Calculate humidity ratio
348
- w = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
349
-
350
- # Calculate wet-bulb temperature
351
- t_wb = Psychrometrics.wet_bulb_temperature(t_db, rh, p_atm)
352
-
353
- # Calculate dew point temperature
354
- t_dp = Psychrometrics.dew_point_temperature(t_db, rh)
355
-
356
- # Calculate enthalpy
 
357
  h = Psychrometrics.enthalpy(t_db, w)
358
-
359
- # Calculate specific volume
360
- v = Psychrometrics.specific_volume(t_db, w, p_atm)
361
-
362
- # Calculate density
363
- rho = Psychrometrics.density(t_db, w, p_atm)
364
-
365
- # Calculate saturation pressure
366
  p_ws = Psychrometrics.saturation_pressure(t_db)
367
-
368
- # Calculate partial pressure of water vapor
369
- p_w = rh / 100.0 * p_ws
370
-
371
- # Return all properties
372
  return {
373
- "dry_bulb_temperature": t_db,
374
- "wet_bulb_temperature": t_wb,
375
- "dew_point_temperature": t_dp,
376
- "relative_humidity": rh,
377
- "humidity_ratio": w,
378
- "enthalpy": h,
379
- "specific_volume": v,
380
- "density": rho,
381
- "saturation_pressure": p_ws,
382
- "partial_pressure": p_w,
383
- "atmospheric_pressure": p_atm
384
  }
385
-
 
386
  @staticmethod
387
  def find_humidity_ratio_for_enthalpy(t_db: float, h: float) -> float:
388
  """
389
  Find humidity ratio for a given dry-bulb temperature and enthalpy.
390
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
391
-
392
  Args:
393
  t_db: Dry-bulb temperature in °C
394
  h: Specific enthalpy in J/kg dry air
395
-
396
  Returns:
397
  Humidity ratio in kg water vapor / kg dry air
398
  """
399
- Psychrometrics.validate_inputs(t_db)
400
- if h < 0:
401
- raise ValueError("Enthalpy cannot be negative")
402
-
403
- c_pa = 1006 # Specific heat of dry air in J/(kg·K)
404
- h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
405
- c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
406
-
407
- w = (h - c_pa * t_db) / (h_fg + c_pw * t_db)
408
-
409
- return max(0, w)
410
-
411
  @staticmethod
412
  def find_temperature_for_enthalpy(w: float, h: float) -> float:
413
  """
414
  Find dry-bulb temperature for a given humidity ratio and enthalpy.
415
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
416
-
417
  Args:
418
  w: Humidity ratio in kg water vapor / kg dry air
419
  h: Specific enthalpy in J/kg dry air
420
-
421
  Returns:
422
  Dry-bulb temperature in °C
423
  """
424
- if w < 0:
425
- raise ValueError("Humidity ratio cannot be negative")
426
- if h < 0:
427
- raise ValueError("Enthalpy cannot be negative")
428
-
429
- c_pa = 1006 # Specific heat of dry air in J/(kg·K)
430
- h_fg = 2501000 # Latent heat of vaporization at 0°C in J/kg
431
- c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
432
-
433
- t_db = (h - w * h_fg) / (c_pa + w * c_pw)
434
-
435
- Psychrometrics.validate_inputs(t_db)
436
  return t_db
437
-
 
438
  @staticmethod
439
  def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
440
  """
441
- Calculate sensible heat ratio.
442
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.5.
443
-
444
  Args:
445
- q_sensible: Sensible heat load in W
446
- q_total: Total heat load in W
447
-
448
  Returns:
449
- Sensible heat ratio (0-1)
450
- """
451
- if q_total == 0:
452
- return 1.0
453
- if q_sensible < 0 or q_total < 0:
454
- raise ValueError("Heat loads cannot be negative")
455
-
456
- return q_sensible / q_total
457
-
458
  @staticmethod
459
- def air_flow_rate_for_load(q_sensible: float, t_supply: float, t_return: float,
460
- rh_return: float = 50.0, p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
 
461
  """
462
- Calculate required air flow rate for a given sensible load.
463
- Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.6.
464
-
465
  Args:
466
- q_sensible: Sensible heat load in W
467
- t_supply: Supply air temperature in °C
468
- t_return: Return air temperature in °C
469
- rh_return: Return air relative humidity in % (default: 50%)
470
- p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
471
-
472
  Returns:
473
- Dictionary with air flow rate in different units
474
- """
475
- Psychrometrics.validate_inputs(t_return, rh_return, p_atm)
476
- Psychrometrics.validate_inputs(t_supply)
477
-
478
- # Calculate return air properties
479
- w_return = Psychrometrics.humidity_ratio(t_return, rh_return, p_atm)
480
- rho_return = Psychrometrics.density(t_return, w_return, p_atm)
481
-
482
- # Calculate specific heat of moist air
483
- c_pa = 1006 # Specific heat of dry air in J/(kg·K)
484
- c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
485
- c_p_moist = c_pa + w_return * c_pw
486
-
487
- # Calculate mass flow rate
488
- delta_t = t_return - t_supply
489
- if delta_t == 0:
490
- raise ValueError("Supply and return temperatures cannot be equal")
491
-
492
- m_dot = q_sensible / (c_p_moist * delta_t)
493
-
494
- # Calculate volumetric flow rate
495
- v_dot = m_dot / rho_return
496
-
497
- # Convert to different units
498
- v_dot_m3_s = v_dot
499
- v_dot_m3_h = v_dot * 3600
500
- v_dot_cfm = v_dot * 2118.88
501
- v_dot_l_s = v_dot * 1000
502
-
503
- return {
504
- "mass_flow_rate_kg_s": m_dot,
505
- "volumetric_flow_rate_m3_s": v_dot_m3_s,
506
- "volumetric_flow_rate_m3_h": v_dot_m3_h,
507
- "volumetric_flow_rate_cfm": v_dot_cfm,
508
- "volumetric_flow_rate_l_s": v_dot_l_s
509
- }
510
-
511
  @staticmethod
512
- def mixing_air_properties(m1: float, t_db1: float, rh1: float,
513
- m2: float, t_db2: float, rh2: float,
514
- p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
515
  """
516
- Calculate properties of mixed airstreams.
517
- Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.7.
518
-
 
519
  Args:
520
- m1: Mass flow rate of airstream 1 in kg/s
521
- t_db1: Dry-bulb temperature of airstream 1 in °C
522
- rh1: Relative humidity of airstream 1 in %
523
- m2: Mass flow rate of airstream 2 in kg/s
524
- t_db2: Dry-bulb temperature of airstream 2 in °C
525
- rh2: Relative humidity of airstream 2 in %
526
- p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
527
-
528
  Returns:
529
- Dictionary with mixed air properties
530
- """
531
- Psychrometrics.validate_inputs(t_db1, rh1, p_atm)
532
- Psychrometrics.validate_inputs(t_db2, rh2, p_atm)
533
- if m1 < 0 or m2 < 0:
534
- raise ValueError("Mass flow rates cannot be negative")
535
-
536
- # Calculate humidity ratios
537
- w1 = Psychrometrics.humidity_ratio(t_db1, rh1, p_atm)
538
- w2 = Psychrometrics.humidity_ratio(t_db2, rh2, p_atm)
539
-
540
- # Calculate enthalpies
541
- h1 = Psychrometrics.enthalpy(t_db1, w1)
542
- h2 = Psychrometrics.enthalpy(t_db2, w2)
543
-
544
- # Calculate mixed air properties
545
- m_total = m1 + m2
546
-
547
- if m_total == 0:
548
- raise ValueError("Total mass flow rate cannot be zero")
549
-
550
- w_mix = (m1 * w1 + m2 * w2) / m_total
551
- h_mix = (m1 * h1 + m2 * h2) / m_total
552
-
553
- # Find dry-bulb temperature for the mixed air
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
555
-
556
- # Calculate relative humidity for the mixed air
557
  rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
558
-
559
- # Return mixed air properties
560
- return Psychrometrics.moist_air_properties(t_db_mix, rh_mix, p_atm)
561
 
 
 
 
 
562
 
563
- # Create a singleton instance
564
- psychrometrics = Psychrometrics()
 
 
 
 
 
565
 
566
- # Example usage
567
  if __name__ == "__main__":
568
- # Calculate properties of air at 25°C and 50% RH
569
- properties = psychrometrics.moist_air_properties(25, 50)
570
-
571
- print("Air Properties at 25°C and 50% RH:")
572
- print(f"Dry-bulb temperature: {properties['dry_bulb_temperature']:.2f} °C")
573
- print(f"Wet-bulb temperature: {properties['wet_bulb_temperature']:.2f} °C")
574
- print(f"Dew point temperature: {properties['dew_point_temperature']:.2f} °C")
575
- print(f"Relative humidity: {properties['relative_humidity']:.2f} %")
576
- print(f"Humidity ratio: {properties['humidity_ratio']:.6f} kg/kg")
577
- print(f"Enthalpy: {properties['enthalpy']/1000:.2f} kJ/kg")
578
- print(f"Specific volume: {properties['specific_volume']:.4f} m³/kg")
579
- print(f"Density: {properties['density']:.4f} kg/m³")
580
- print(f"Saturation pressure: {properties['saturation_pressure']/1000:.2f} kPa")
581
- print(f"Partial pressure: {properties['partial_pressure']/1000:.2f} kPa")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  Psychrometric module for HVAC Load Calculator.
3
+ This module implements psychrometric calculations for air properties,
4
+ including functions for mixing air streams and handling different altitudes.
5
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
6
+
7
+ Author: Dr Majed Abuseif
8
+ Date: May 2025 (Enhanced based on plan, preserving original features)
9
+ Version: 1.3.0
10
  """
11
 
12
  from typing import Dict, List, Any, Optional, Tuple
13
  import math
14
  import numpy as np
15
+ import logging
16
+
17
+ # Set up logging
18
+ logger = logging.getLogger(__name__)
19
 
20
+ # Constants (Preserved from original)
21
+ ATMOSPHERIC_PRESSURE = 101325 # Standard atmospheric pressure at sea level in Pa
22
  WATER_MOLECULAR_WEIGHT = 18.01534 # kg/kmol
23
  DRY_AIR_MOLECULAR_WEIGHT = 28.9645 # kg/kmol
24
  UNIVERSAL_GAS_CONSTANT = 8314.462618 # J/(kmol·K)
25
+ GAS_CONSTANT_DRY_AIR = UNIVERSAL_GAS_CONSTANT / DRY_AIR_MOLECULAR_WEIGHT # J/(kg·K) = 287.058
26
+ GAS_CONSTANT_WATER_VAPOR = UNIVERSAL_GAS_CONSTANT / WATER_MOLECULAR_WEIGHT # J/(kg·K) = 461.52
27
 
28
+ # Constants for altitude calculation (Standard Atmosphere Model)
29
+ SEA_LEVEL_TEMP_K = 288.15 # K (15 °C)
30
+ LAPSE_RATE = 0.0065 # K/m
31
+ GRAVITY = 9.80665 # m/s²
32
 
33
  class Psychrometrics:
34
  """Class for psychrometric calculations."""
35
+
36
+ # --- Input Validation (Preserved and slightly enhanced) --- #
37
  @staticmethod
38
+ def validate_inputs(t_db: Optional[float] = None, rh: Optional[float] = None,
39
+ w: Optional[float] = None, h: Optional[float] = None,
40
+ p_atm: Optional[float] = None) -> None:
41
  """
42
  Validate input parameters for psychrometric calculations.
 
43
  Args:
44
  t_db: Dry-bulb temperature in °C
45
+ rh: Relative humidity in % (0-100)
46
+ w: Humidity ratio (kg/kg)
47
+ h: Enthalpy (J/kg)
48
+ p_atm: Atmospheric pressure in Pa
49
  Raises:
50
  ValueError: If inputs are invalid
51
  """
52
+ if t_db is not None and not -100 <= t_db <= 200: # Wider range for intermediate calcs
53
+ raise ValueError(f"Temperature {t_db}°C must be within a reasonable range (-100°C to 200°C)")
54
  if rh is not None and not 0 <= rh <= 100:
55
+ # Allow slightly > 100 due to calculation tolerances, clamp later
56
+ if rh < 0 or rh > 105:
57
+ raise ValueError(f"Relative humidity {rh}% must be between 0 and 100%")
58
+ if w is not None and w < 0:
59
+ raise ValueError(f"Humidity ratio {w} cannot be negative")
60
+ # Enthalpy can be negative relative to datum
61
+ # if h is not None and h < 0:
62
+ # raise ValueError(f"Enthalpy {h} cannot be negative")
63
+ if p_atm is not None and not 10000 <= p_atm <= 120000: # Typical atmospheric range
64
+ raise ValueError(f"Atmospheric pressure {p_atm} Pa must be within a reasonable range (10kPa to 120kPa)")
65
+
66
+ # --- Altitude/Pressure Calculation (Added based on plan) --- #
67
+ @staticmethod
68
+ def pressure_at_altitude(altitude: float, sea_level_pressure: float = ATMOSPHERIC_PRESSURE,
69
+ sea_level_temp_c: float = 15.0) -> float:
70
+ """
71
+ Calculate atmospheric pressure at a given altitude using the standard atmosphere model.
72
+ Reference: https://en.wikipedia.org/wiki/Barometric_formula
73
+ Args:
74
+ altitude: Altitude above sea level in meters.
75
+ sea_level_pressure: Pressure at sea level in Pa (default: 101325 Pa).
76
+ sea_level_temp_c: Temperature at sea level in °C (default: 15 °C).
77
+ Returns:
78
+ Atmospheric pressure at the given altitude in Pa.
79
+ """
80
+ if altitude < -500 or altitude > 80000: # Valid range for model
81
+ logger.warning(f"Altitude {altitude}m is outside the typical range for the standard atmosphere model.")
82
+
83
+ sea_level_temp_k = sea_level_temp_c + 273.15
84
+ r_da = GAS_CONSTANT_DRY_AIR
85
+
86
+ # Formula assumes constant lapse rate up to 11km
87
+ if altitude <= 11000:
88
+ temp_k = sea_level_temp_k - LAPSE_RATE * altitude
89
+ pressure = sea_level_pressure * (temp_k / sea_level_temp_k) ** (GRAVITY / (LAPSE_RATE * r_da))
90
+ else:
91
+ # Simplified: Use constant temperature above 11km (tropopause)
92
+ # A more complex model is needed for higher altitudes
93
+ logger.warning("Altitude > 11km. Using simplified pressure calculation.")
94
+ temp_11km = sea_level_temp_k - LAPSE_RATE * 11000
95
+ pressure_11km = sea_level_pressure * (temp_11km / sea_level_temp_k) ** (GRAVITY / (LAPSE_RATE * r_da))
96
+ pressure = pressure_11km * math.exp(-GRAVITY * (altitude - 11000) / (r_da * temp_11km))
97
 
98
+ return pressure
99
+
100
+ # --- Core Psychrometric Functions (Preserved from original) --- #
101
  @staticmethod
102
  def saturation_pressure(t_db: float) -> float:
103
  """
104
  Calculate saturation pressure of water vapor.
105
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5 and 6.
 
106
  Args:
107
  t_db: Dry-bulb temperature in °C
 
108
  Returns:
109
  Saturation pressure in Pa
110
  """
111
+ # Input validation is implicitly handled by usage, but can be added
112
+ # Psychrometrics.validate_inputs(t_db=t_db)
113
+
114
  t_k = t_db + 273.15
115
+
116
+ if t_k <= 0:
117
+ # Avoid issues with log(T) or 1/T at or below absolute zero
118
+ return 0.0
119
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  if t_db >= 0:
121
+ # Eq 6 (ASHRAE 2017) - Renamed from Eq 5 in older versions
122
+ C1 = -5.8002206E+03
123
+ C2 = 1.3914993E+00
124
+ C3 = -4.8640239E-02
125
+ C4 = 4.1764768E-05
126
+ C5 = -1.4452093E-08
127
+ C6 = 6.5459673E+00
128
+ ln_p_ws = C1/t_k + C2 + C3*t_k + C4*t_k**2 + C5*t_k**3 + C6*math.log(t_k)
129
  else:
130
+ # Eq 5 (ASHRAE 2017) - Renamed from Eq 6 in older versions
131
+ C7 = -5.6745359E+03
132
+ C8 = 6.3925247E+00
133
+ C9 = -9.6778430E-03
134
+ C10 = 6.2215701E-07
135
+ C11 = 2.0747825E-09
136
+ C12 = -9.4840240E-13
137
+ C13 = 4.1635019E+00
138
+ ln_p_ws = C7/t_k + C8 + C9*t_k + C10*t_k**2 + C11*t_k**3 + C12*t_k**4 + C13*math.log(t_k)
139
+
140
  p_ws = math.exp(ln_p_ws)
 
141
  return p_ws
142
+
143
  @staticmethod
144
  def humidity_ratio(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
145
  """
146
  Calculate humidity ratio (mass of water vapor per unit mass of dry air).
147
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 20, 12.
 
148
  Args:
149
  t_db: Dry-bulb temperature in °C
150
  rh: Relative humidity (0-100)
151
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
 
152
  Returns:
153
  Humidity ratio in kg water vapor / kg dry air
154
  """
155
+ Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm)
156
+ rh_decimal = max(0.0, min(1.0, rh / 100.0)) # Clamp RH
 
 
 
 
157
  p_ws = Psychrometrics.saturation_pressure(t_db)
158
+ p_w = rh_decimal * p_ws # Eq 12
159
+
160
+ # Check if partial pressure exceeds atmospheric pressure (physically impossible)
 
161
  if p_w >= p_atm:
162
+ # This usually indicates very high temp or incorrect pressure
163
+ logger.warning(f"Calculated partial pressure {p_w:.1f} Pa >= atmospheric pressure {p_atm:.1f} Pa at T={t_db}°C, RH={rh}%. Clamping humidity ratio.")
164
+ # Return saturation humidity ratio at p_atm (boiling point)
165
+ p_w_sat_at_p_atm = p_atm # Water boils when p_ws = p_atm
166
+ w = 0.621945 * p_w_sat_at_p_atm / (p_atm - p_w_sat_at_p_atm + 1e-9) # Add small epsilon to avoid division by zero
167
+ return w
168
+ # raise ValueError(f"Partial pressure {p_w:.1f} Pa cannot exceed atmospheric pressure {p_atm:.1f} Pa")
169
+
170
+ # Eq 20
171
  w = 0.621945 * p_w / (p_atm - p_w)
172
+ return max(0.0, w) # Ensure non-negative
173
+
 
174
  @staticmethod
175
  def relative_humidity(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
176
  """
177
  Calculate relative humidity from humidity ratio.
178
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 22, 12.
 
179
  Args:
180
  t_db: Dry-bulb temperature in °C
181
  w: Humidity ratio in kg water vapor / kg dry air
182
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
 
183
  Returns:
184
  Relative humidity (0-100)
185
  """
186
+ Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
187
+ w = max(0.0, w) # Ensure non-negative
 
 
 
188
  p_ws = Psychrometrics.saturation_pressure(t_db)
189
+
190
+ # Eq 22 (Rearranged from Eq 20)
191
  p_w = p_atm * w / (0.621945 + w)
192
+
193
+ if p_ws <= 0:
194
+ # Avoid division by zero at very low temperatures
195
+ return 0.0
196
+
197
+ # Eq 12 (Definition of RH)
198
  rh = 100.0 * p_w / p_ws
199
+ return max(0.0, min(100.0, rh)) # Clamp RH between 0 and 100
200
+
 
201
  @staticmethod
202
+ def wet_bulb_temperature(t_db: float, rh: Optional[float] = None, w: Optional[float] = None,
203
+ p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
204
  """
205
+ Calculate wet-bulb temperature using an iterative method or direct formula if applicable.
206
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 33, 35.
207
+ Stull, R. (2011). "Wet-Bulb Temperature from Relative Humidity and Air Temperature". Journal of Applied Meteorology and Climatology.
208
+
209
  Args:
210
  t_db: Dry-bulb temperature in °C
211
+ rh: Relative humidity (0-100) (either rh or w must be provided)
212
+ w: Humidity ratio (kg/kg) (either rh or w must be provided)
213
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
 
214
  Returns:
215
  Wet-bulb temperature in °C
216
  """
217
+ if rh is None and w is None:
218
+ raise ValueError("Either relative humidity (rh) or humidity ratio (w) must be provided.")
219
+ if rh is not None:
220
+ Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm)
221
+ w_actual = Psychrometrics.humidity_ratio(t_db, rh, p_atm)
222
+ elif w is not None:
223
+ Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
224
+ w_actual = w
225
+ else:
226
+ raise ValueError("Calculation error in wet_bulb_temperature input handling.") # Should not happen
227
+
228
+ # --- Using Stull's empirical formula (approximation) --- #
229
+ # Provides a good initial guess or can be used directly for moderate accuracy
230
+ try:
231
+ rh_actual = Psychrometrics.relative_humidity(t_db, w_actual, p_atm)
232
+ rh_decimal = rh_actual / 100.0
233
+ t_wb_stull = (t_db * math.atan(0.151977 * (rh_actual + 8.313659)**0.5) +
234
+ math.atan(t_db + rh_actual) -
235
+ math.atan(rh_actual - 1.676331) +
236
+ 0.00391838 * (rh_actual**1.5) * math.atan(0.023101 * rh_actual) -
237
+ 4.686035)
238
+ # Check if Stull's result is reasonable (e.g., t_wb <= t_db)
239
+ if t_wb_stull <= t_db and abs(t_wb_stull - t_db) < 50: # Basic sanity check
240
+ # Use Stull's value as a very good starting point for iteration
241
+ t_wb_guess = t_wb_stull
242
+ else:
243
+ t_wb_guess = t_db * 0.8 # Fallback guess
244
+ except Exception:
245
+ t_wb_guess = t_db * 0.8 # Fallback guess if Stull's formula fails
246
+
247
+ # --- Iterative solution based on ASHRAE Eq 33/35 --- #
248
+ t_wb = t_wb_guess
249
  max_iterations = 100
250
+ tolerance_w = 1e-7 # Tolerance on humidity ratio
251
+
252
  for i in range(max_iterations):
253
+ # Saturation humidity ratio at current guess of t_wb
 
 
 
254
  p_ws_wb = Psychrometrics.saturation_pressure(t_wb)
 
 
255
  w_s_wb = 0.621945 * p_ws_wb / (p_atm - p_ws_wb)
256
+ w_s_wb = max(0.0, w_s_wb)
257
+
258
+ # Humidity ratio calculated from energy balance (Eq 33/35 rearranged)
259
+ # Using simplified specific heats for this iterative approach
260
+ c_pa = 1006 # J/(kg·K)
261
+ c_pw = 1860 # J/(kg·K)
262
+ h_fg_wb = Psychrometrics.latent_heat_of_vaporization(t_wb) # J/kg
263
+
264
+ # Eq 35 rearranged to find W based on Tdb, Twb, Ws_wb
265
+ numerator = (c_pa + w_s_wb * c_pw) * t_wb - c_pa * t_db
266
+ denominator = (c_pa + w_s_wb * c_pw) * t_wb - (c_pw * t_db + h_fg_wb)
267
+ # Avoid division by zero if denominator is close to zero
268
+ if abs(denominator) < 1e-6:
269
+ # This might happen near saturation, check if w_actual is close to w_s_wb
270
+ if abs(w_actual - w_s_wb) < tolerance_w * 10:
271
+ break # Converged near saturation
272
+ else:
273
+ # Adjust guess differently if denominator is zero
274
+ t_wb -= 0.05 * (1 if w_s_wb > w_actual else -1)
275
+ continue
276
+
277
+ w_calc_from_wb = w_s_wb + numerator / denominator
278
+
279
  # Check convergence
280
+ if abs(w_actual - w_calc_from_wb) < tolerance_w:
281
  break
282
+
283
+ # Adjust wet-bulb temperature guess (simple step adjustment)
284
+ # A more sophisticated root-finding method (like Newton-Raphson) could be used here
285
+ step = 0.1 # Initial step size
286
+ if i > 10: step = 0.01 # Smaller steps later
287
+ if i > 50: step = 0.001
288
+
289
+ if w_calc_from_wb > w_actual:
290
+ t_wb -= step # Calculated W is too high, need lower Twb
291
  else:
292
+ t_wb += step # Calculated W is too low, need higher Twb
293
+
294
+ # Ensure t_wb doesn't exceed t_db
295
+ t_wb = min(t_wb, t_db)
296
+
297
+ else:
298
+ # If loop finishes without break, convergence failed
299
+ logger.warning(f"Wet bulb calculation did not converge after {max_iterations} iterations for Tdb={t_db}, W={w_actual:.6f}. Result: {t_wb:.3f}")
300
+
301
+ # Ensure Twb <= Tdb
302
+ return min(t_wb, t_db)
303
+
304
  @staticmethod
305
+ def dew_point_temperature(t_db: Optional[float] = None, rh: Optional[float] = None,
306
+ w: Optional[float] = None, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
307
  """
308
  Calculate dew point temperature.
309
+ Uses the relationship Tdp = T(Pw) where Pw is partial pressure.
310
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equations 5, 6, 37.
311
  Args:
312
+ t_db: Dry-bulb temperature in °C (required if rh is given)
313
+ rh: Relative humidity (0-100) (either rh or w must be provided)
314
+ w: Humidity ratio (kg/kg) (either rh or w must be provided)
315
+ p_atm: Atmospheric pressure in Pa (required if w is given)
316
  Returns:
317
  Dew point temperature in °C
318
  """
319
+ if rh is None and w is None:
320
+ raise ValueError("Either relative humidity (rh) or humidity ratio (w) must be provided.")
321
+
322
+ if rh is not None:
323
+ if t_db is None:
324
+ raise ValueError("Dry-bulb temperature (t_db) must be provided if relative humidity (rh) is given.")
325
+ Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm)
326
+ rh_decimal = max(0.0, min(1.0, rh / 100.0))
327
+ p_ws = Psychrometrics.saturation_pressure(t_db)
328
+ p_w = rh_decimal * p_ws
329
+ elif w is not None:
330
+ Psychrometrics.validate_inputs(w=w, p_atm=p_atm)
331
+ w = max(0.0, w)
332
+ # Eq 22 (Rearranged from Eq 20)
333
+ p_w = p_atm * w / (0.621945 + w)
334
+ else:
335
+ raise ValueError("Calculation error in dew_point_temperature input handling.") # Should not happen
336
+
337
+ if p_w <= 0:
338
+ # Handle case of zero humidity
339
+ return -100.0 # Or some other indicator of very dry air
340
+
341
+ # Find temperature at which saturation pressure equals partial pressure p_w
342
+ # This requires inverting the saturation pressure formula (Eq 5/6)
343
+ # Using iterative approach or approximation formula (like Magnus formula or ASHRAE Eq 37/38)
344
+
345
+ # Using ASHRAE 2017 Eq 37 & 38 (approximation)
346
+ alpha = math.log(p_w / 610.71) # Note: ASHRAE uses Pw in Pa, but older formulas used kPa. Using Pa here. Ref: Eq 3/4
347
+
348
+ # Eq 38 for Tdp >= 0
349
+ t_dp_pos = (18.678 - alpha / 234.5) * alpha / (257.14 + alpha / 234.5 * alpha)
350
+ # Eq 37 for Tdp < 0
351
+ t_dp_neg = 6.09 + 12.608 * alpha + 0.4959 * alpha**2 # This seems less accurate based on testing
352
+
353
+ # Alternative Magnus formula approximation (often used):
354
+ # Constants for Magnus formula (approximation)
355
+ # A = 17.625
356
+ # B = 243.04
357
+ # gamma = math.log(rh_decimal) + (A * t_db) / (B + t_db)
358
+ # t_dp_magnus = (B * gamma) / (A - gamma)
359
+
360
+ # Iterative approach for higher accuracy (finding T such that Pws(T) = Pw)
361
+ # Start guess near Tdb or using approximation
362
+ t_dp_guess = t_dp_pos # Use ASHRAE approximation as starting point
363
+ max_iterations = 20
364
+ tolerance_p = 0.1 # Pa tolerance
365
+
366
+ for i in range(max_iterations):
367
+ p_ws_at_guess = Psychrometrics.saturation_pressure(t_dp_guess)
368
+ error = p_w - p_ws_at_guess
369
+
370
+ if abs(error) < tolerance_p:
371
+ break
372
+
373
+ # Estimate derivative d(Pws)/dT (Clausius-Clapeyron approximation)
374
+ # L = Psychrometrics.latent_heat_of_vaporization(t_dp_guess)
375
+ # Rv = GAS_CONSTANT_WATER_VAPOR
376
+ # T_k = t_dp_guess + 273.15
377
+ # dP_dT = (p_ws_at_guess * L) / (Rv * T_k**2)
378
+ # A simpler approximation for derivative:
379
+ p_ws_plus = Psychrometrics.saturation_pressure(t_dp_guess + 0.01)
380
+ dP_dT = (p_ws_plus - p_ws_at_guess) / 0.01
381
+
382
+ if abs(dP_dT) < 1e-3: # Avoid division by small number if derivative is near zero
383
+ break
384
+
385
+ # Newton-Raphson step
386
+ t_dp_guess += error / dP_dT
387
  else:
388
+ logger.debug(f"Dew point iteration did not fully converge for Pw={p_w:.2f} Pa. Result: {t_dp_guess:.3f}")
389
+
390
+ return t_dp_guess
391
+
392
+ @staticmethod
393
+ def latent_heat_of_vaporization(t_db: float) -> float:
394
+ """
395
+ Calculate latent heat of vaporization of water at a given temperature.
396
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 2.
397
+ Args:
398
+ t_db: Dry-bulb temperature in °C
399
+ Returns:
400
+ Latent heat of vaporization (h_fg) in J/kg
401
+ """
402
+ # Eq 2 (Approximation)
403
+ h_fg = (2501 - 2.361 * t_db) * 1000 # Convert kJ/kg to J/kg
404
+ return h_fg
405
+
406
  @staticmethod
407
  def enthalpy(t_db: float, w: float) -> float:
408
  """
409
  Calculate specific enthalpy of moist air.
410
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30.
411
+ Datum: 0 J/kg for dry air at 0°C, 0 J/kg for saturated liquid water at 0°C.
412
  Args:
413
  t_db: Dry-bulb temperature in °C
414
  w: Humidity ratio in kg water vapor / kg dry air
 
415
  Returns:
416
  Specific enthalpy in J/kg dry air
417
  """
418
+ Psychrometrics.validate_inputs(t_db=t_db, w=w)
419
+ w = max(0.0, w)
420
+
421
+ # Using more accurate specific heats if needed, but ASHRAE Eq 30 uses constants:
422
+ c_pa = 1006 # Specific heat of dry air in J/(kg·K)
423
+ h_g0 = 2501000 # Enthalpy of water vapor at 0°C in J/kg
424
+ c_pw = 1860 # Specific heat of water vapor in J/(kg·K)
425
+
426
+ # Eq 30
427
+ h = c_pa * t_db + w * (h_g0 + c_pw * t_db)
428
  return h
429
+
430
  @staticmethod
431
  def specific_volume(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
432
  """
433
  Calculate specific volume of moist air.
434
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 26.
 
435
  Args:
436
  t_db: Dry-bulb temperature in °C
437
  w: Humidity ratio in kg water vapor / kg dry air
438
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
 
439
  Returns:
440
  Specific volume in m³/kg dry air
441
  """
442
+ Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
443
+ w = max(0.0, w)
 
 
 
444
  t_k = t_db + 273.15
445
+ r_da = GAS_CONSTANT_DRY_AIR
446
+
447
+ # Eq 26 (Ideal Gas Law for moist air)
448
+ # Factor 1.607858 is Ratio of MW_air / MW_water approx (28.9645 / 18.01534)
449
+ v = (r_da * t_k / p_atm) * (1 + 1.607858 * w)
450
  return v
451
+
452
  @staticmethod
453
  def density(t_db: float, w: float, p_atm: float = ATMOSPHERIC_PRESSURE) -> float:
454
  """
455
  Calculate density of moist air.
456
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, derived from Equation 26.
457
+ Density = Mass / Volume = (Mass Dry Air + Mass Water Vapor) / Volume
458
+ = (1 + w) / specific_volume
459
  Args:
460
  t_db: Dry-bulb temperature in °C
461
  w: Humidity ratio in kg water vapor / kg dry air
462
  p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure)
 
463
  Returns:
464
+ Density in kg moist air /
465
+ """
466
+ Psychrometrics.validate_inputs(t_db=t_db, w=w, p_atm=p_atm)
467
+ w = max(0.0, w)
468
+ v = Psychrometrics.specific_volume(t_db, w, p_atm) # m³/kg dry air
469
+ if v <= 0:
470
+ raise ValueError("Calculated specific volume is non-positive, cannot calculate density.")
471
+ # Density = mass_total / volume = (mass_dry_air + mass_water) / volume
472
+ # Since v = volume / mass_dry_air, then density = (1 + w) / v
 
473
  rho = (1 + w) / v
 
474
  return rho
475
+
476
+ # --- Comprehensive Property Calculation (Preserved) --- #
477
  @staticmethod
478
+ def moist_air_properties(t_db: float, rh: float, p_atm: float = ATMOSPHERIC_PRESSURE,
479
+ altitude: Optional[float] = None) -> Dict[str, float]:
480
  """
481
  Calculate all psychrometric properties of moist air.
482
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1.
 
483
  Args:
484
  t_db: Dry-bulb temperature in °C
485
  rh: Relative humidity (0-100)
486
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure).
487
+ If altitude is provided, p_atm is calculated and this value is ignored.
488
+ altitude: Altitude in meters (optional). If provided, calculates pressure at altitude.
489
  Returns:
490
+ Dictionary with all psychrometric properties.
491
+ """
492
+ if altitude is not None:
493
+ p_atm_calc = Psychrometrics.pressure_at_altitude(altitude)
494
+ logger.debug(f"Calculated pressure at altitude {altitude}m: {p_atm_calc:.0f} Pa")
495
+ p_atm_used = p_atm_calc
496
+ else:
497
+ p_atm_used = p_atm
498
+
499
+ Psychrometrics.validate_inputs(t_db=t_db, rh=rh, p_atm=p_atm_used)
500
+ rh_clamped = max(0.0, min(100.0, rh))
501
+
502
+ w = Psychrometrics.humidity_ratio(t_db, rh_clamped, p_atm_used)
503
+ t_wb = Psychrometrics.wet_bulb_temperature(t_db, rh=rh_clamped, w=w, p_atm=p_atm_used)
504
+ t_dp = Psychrometrics.dew_point_temperature(t_db=t_db, rh=rh_clamped, w=w, p_atm=p_atm_used)
505
  h = Psychrometrics.enthalpy(t_db, w)
506
+ v = Psychrometrics.specific_volume(t_db, w, p_atm_used)
507
+ rho = Psychrometrics.density(t_db, w, p_atm_used)
 
 
 
 
 
 
508
  p_ws = Psychrometrics.saturation_pressure(t_db)
509
+ p_w = (rh_clamped / 100.0) * p_ws
510
+
 
 
 
511
  return {
512
+ "dry_bulb_temperature_c": t_db,
513
+ "wet_bulb_temperature_c": t_wb,
514
+ "dew_point_temperature_c": t_dp,
515
+ "relative_humidity_percent": rh_clamped,
516
+ "humidity_ratio_kg_kg": w,
517
+ "enthalpy_j_kg": h,
518
+ "specific_volume_m3_kg": v,
519
+ "density_kg_m3": rho,
520
+ "saturation_pressure_pa": p_ws,
521
+ "partial_pressure_pa": p_w,
522
+ "atmospheric_pressure_pa": p_atm_used
523
  }
524
+
525
+ # --- Inverse Functions (Preserved) --- #
526
  @staticmethod
527
  def find_humidity_ratio_for_enthalpy(t_db: float, h: float) -> float:
528
  """
529
  Find humidity ratio for a given dry-bulb temperature and enthalpy.
530
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
 
531
  Args:
532
  t_db: Dry-bulb temperature in °C
533
  h: Specific enthalpy in J/kg dry air
 
534
  Returns:
535
  Humidity ratio in kg water vapor / kg dry air
536
  """
537
+ Psychrometrics.validate_inputs(t_db=t_db, h=h)
538
+ c_pa = 1006
539
+ h_g0 = 2501000
540
+ c_pw = 1860
541
+ denominator = (h_g0 + c_pw * t_db)
542
+ if abs(denominator) < 1e-6:
543
+ # Avoid division by zero, happens at specific low temps where denominator is zero
544
+ logger.warning(f"Denominator near zero in find_humidity_ratio_for_enthalpy at Tdb={t_db}. Enthalpy {h} may be inconsistent.")
545
+ return 0.0 # Or raise error
546
+ w = (h - c_pa * t_db) / denominator
547
+ return max(0.0, w) # Humidity ratio cannot be negative
548
+
549
  @staticmethod
550
  def find_temperature_for_enthalpy(w: float, h: float) -> float:
551
  """
552
  Find dry-bulb temperature for a given humidity ratio and enthalpy.
553
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Equation 30 (rearranged).
 
554
  Args:
555
  w: Humidity ratio in kg water vapor / kg dry air
556
  h: Specific enthalpy in J/kg dry air
 
557
  Returns:
558
  Dry-bulb temperature in °C
559
  """
560
+ Psychrometrics.validate_inputs(w=w, h=h)
561
+ w = max(0.0, w)
562
+ c_pa = 1006
563
+ h_g0 = 2501000
564
+ c_pw = 1860
565
+ denominator = (c_pa + w * c_pw)
566
+ if abs(denominator) < 1e-6:
567
+ raise ValueError(f"Cannot calculate temperature: denominator (Cp_a + w*Cp_w) is near zero for w={w}")
568
+ t_db = (h - w * h_g0) / denominator
569
+ # Validate the result is within reasonable bounds
570
+ Psychrometrics.validate_inputs(t_db=t_db)
 
571
  return t_db
572
+
573
+ # --- Heat Ratio and Flow Rate (Preserved) --- #
574
  @staticmethod
575
  def sensible_heat_ratio(q_sensible: float, q_total: float) -> float:
576
  """
577
+ Calculate sensible heat ratio (SHR).
578
  Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.5.
 
579
  Args:
580
+ q_sensible: Sensible heat load in W (can be negative for cooling)
581
+ q_total: Total heat load in W (sensible + latent) (can be negative for cooling)
 
582
  Returns:
583
+ Sensible heat ratio (typically 0 to 1 for cooling, can be >1 or <0 in some cases)
584
+ """
585
+ if abs(q_total) < 1e-9: # Avoid division by zero
586
+ # If total load is zero, SHR is undefined or can be considered 1 if only sensible exists
587
+ return 1.0 if abs(q_sensible) < 1e-9 else (1.0 if q_sensible > 0 else -1.0) # Or np.nan
588
+ shr = q_sensible / q_total
589
+ return shr
590
+
 
591
  @staticmethod
592
+ def air_flow_rate_for_load(q_sensible: float, delta_t: float,
593
+ rho: Optional[float] = None, cp: float = 1006,
594
+ altitude: Optional[float] = None) -> float:
595
  """
596
+ Calculate volumetric air flow rate required to meet a sensible load.
597
+ Formula: q_sensible = m_dot * cp * delta_t = (rho * V_dot) * cp * delta_t
598
+ V_dot = q_sensible / (rho * cp * delta_t)
599
  Args:
600
+ q_sensible: Sensible heat load in W.
601
+ delta_t: Temperature difference between supply and return air in °C (or K).
602
+ rho: Density of air in kg/m³ (optional, will use standard density if None).
603
+ cp: Specific heat of air in J/(kg·K) (default: 1006).
604
+ altitude: Altitude in meters (optional, used to estimate density if rho is None).
 
605
  Returns:
606
+ Volumetric air flow rate (V_dot) in m³/s.
607
+ """
608
+ if abs(delta_t) < 1e-6:
609
+ raise ValueError("Delta T cannot be zero for air flow rate calculation.")
610
+
611
+ if rho is None:
612
+ # Estimate density based on typical conditions or altitude
613
+ if altitude is not None:
614
+ p_atm_alt = Psychrometrics.pressure_at_altitude(altitude)
615
+ # Assume typical indoor conditions for density calculation
616
+ rho = Psychrometrics.density(t_db=22, w=0.008, p_atm=p_atm_alt)
617
+ else:
618
+ # Use standard sea level density as approximation
619
+ rho = Psychrometrics.density(t_db=20, w=0.0075) # Approx 1.2 kg/m³
620
+ logger.debug(f"Using estimated air density: {rho:.3f} kg/m³")
621
+
622
+ if rho <= 0:
623
+ raise ValueError("Air density must be positive.")
624
+
625
+ v_dot = q_sensible / (rho * cp * delta_t)
626
+ return v_dot
627
+
628
+ # --- Air Mixing Function (Added based on plan) --- #
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
  @staticmethod
630
+ def mix_air_streams(stream1: Dict[str, float], stream2: Dict[str, float],
631
+ p_atm: float = ATMOSPHERIC_PRESSURE) -> Dict[str, float]:
 
632
  """
633
+ Calculate the properties of a mixture of two moist air streams.
634
+ Assumes adiabatic mixing at constant pressure.
635
+ Reference: ASHRAE Handbook—Fundamentals (2017), Chapter 1, Section 1.4.
636
+
637
  Args:
638
+ stream1: Dict for stream 1 containing keys: 'flow_rate' (m³/s), 't_db' (°C), 'rh' (%) OR 'w' (kg/kg).
639
+ stream2: Dict for stream 2 containing keys: 'flow_rate' (m³/s), 't_db' (°C), 'rh' (%) OR 'w' (kg/kg).
640
+ p_atm: Atmospheric pressure in Pa (default: standard atmospheric pressure).
641
+
 
 
 
 
642
  Returns:
643
+ Dictionary with properties of the mixed stream: 't_db', 'w', 'rh', 'h', 'flow_rate'.
644
+ Raises:
645
+ ValueError: If input dictionaries are missing required keys or have invalid values.
646
+ """
647
+
648
+ # Validate inputs and get full properties for each stream
649
+ props1 = {}
650
+ props2 = {}
651
+ try:
652
+ t_db1 = stream1['t_db']
653
+ flow1 = stream1['flow_rate']
654
+ if 'rh' in stream1:
655
+ props1 = Psychrometrics.moist_air_properties(t_db1, stream1['rh'], p_atm)
656
+ elif 'w' in stream1:
657
+ w1 = stream1['w']
658
+ Psychrometrics.validate_inputs(t_db=t_db1, w=w1, p_atm=p_atm)
659
+ props1 = Psychrometrics.moist_air_properties(t_db1, Psychrometrics.relative_humidity(t_db1, w1, p_atm), p_atm)
660
+ else:
661
+ raise ValueError("Stream 1 must contain 'rh' or 'w'.")
662
+ if flow1 < 0: raise ValueError("Stream 1 flow rate cannot be negative.")
663
+ m_dot1 = flow1 * props1['density_kg_m3'] # Mass flow rate kg/s
664
+
665
+ t_db2 = stream2['t_db']
666
+ flow2 = stream2['flow_rate']
667
+ if 'rh' in stream2:
668
+ props2 = Psychrometrics.moist_air_properties(t_db2, stream2['rh'], p_atm)
669
+ elif 'w' in stream2:
670
+ w2 = stream2['w']
671
+ Psychrometrics.validate_inputs(t_db=t_db2, w=w2, p_atm=p_atm)
672
+ props2 = Psychrometrics.moist_air_properties(t_db2, Psychrometrics.relative_humidity(t_db2, w2, p_atm), p_atm)
673
+ else:
674
+ raise ValueError("Stream 2 must contain 'rh' or 'w'.")
675
+ if flow2 < 0: raise ValueError("Stream 2 flow rate cannot be negative.")
676
+ m_dot2 = flow2 * props2['density_kg_m3'] # Mass flow rate kg/s
677
+
678
+ except KeyError as e:
679
+ raise ValueError(f"Missing required key in input stream dictionary: {e}")
680
+ except ValueError as e:
681
+ raise ValueError(f"Invalid input value: {e}")
682
+
683
+ # Total mass flow rate
684
+ m_dot_mix = m_dot1 + m_dot2
685
+
686
+ if m_dot_mix <= 1e-9: # Avoid division by zero if total flow is zero
687
+ logger.warning("Total mass flow rate for mixing is zero. Returning properties of stream 1 (or empty dict if flow1 is also zero).")
688
+ if m_dot1 > 1e-9:
689
+ return {
690
+ 't_db': props1['dry_bulb_temperature_c'],
691
+ 'w': props1['humidity_ratio_kg_kg'],
692
+ 'rh': props1['relative_humidity_percent'],
693
+ 'h': props1['enthalpy_j_kg'],
694
+ 'flow_rate': flow1
695
+ }
696
+ else: # Both flows are zero
697
+ return {'t_db': 0, 'w': 0, 'rh': 0, 'h': 0, 'flow_rate': 0}
698
+
699
+ # Mass balance for humidity ratio
700
+ w_mix = (m_dot1 * props1['humidity_ratio_kg_kg'] + m_dot2 * props2['humidity_ratio_kg_kg']) / m_dot_mix
701
+
702
+ # Energy balance for enthalpy
703
+ h_mix = (m_dot1 * props1['enthalpy_j_kg'] + m_dot2 * props2['enthalpy_j_kg']) / m_dot_mix
704
+
705
+ # Find mixed temperature from mixed enthalpy and humidity ratio
706
  t_db_mix = Psychrometrics.find_temperature_for_enthalpy(w_mix, h_mix)
707
+
708
+ # Find mixed relative humidity
709
  rh_mix = Psychrometrics.relative_humidity(t_db_mix, w_mix, p_atm)
 
 
 
710
 
711
+ # Calculate mixed flow rate (volume)
712
+ # Need density at mixed conditions
713
+ rho_mix = Psychrometrics.density(t_db_mix, w_mix, p_atm)
714
+ flow_mix = m_dot_mix / rho_mix if rho_mix > 0 else 0
715
 
716
+ return {
717
+ 't_db': t_db_mix,
718
+ 'w': w_mix,
719
+ 'rh': rh_mix,
720
+ 'h': h_mix,
721
+ 'flow_rate': flow_mix
722
+ }
723
 
724
+ # Example Usage (Preserved and expanded)
725
  if __name__ == "__main__":
726
+ # Test basic properties
727
+ t_db_test = 25.0
728
+ rh_test = 50.0
729
+ p_atm_test = 101325.0
730
+ altitude_test = 1500 # meters
731
+
732
+ print(f"--- Properties at T={t_db_test}°C, RH={rh_test}%, P={p_atm_test} Pa ---")
733
+ props_sea_level = Psychrometrics.moist_air_properties(t_db_test, rh_test, p_atm_test)
734
+ for key, value in props_sea_level.items():
735
+ print(f"{key}: {value:.6f}")
736
+
737
+ print(f"\n--- Properties at T={t_db_test}°C, RH={rh_test}%, Altitude={altitude_test} m ---")
738
+ props_altitude = Psychrometrics.moist_air_properties(t_db_test, rh_test, altitude=altitude_test)
739
+ for key, value in props_altitude.items():
740
+ print(f"{key}: {value:.6f}")
741
+ p_calc_alt = Psychrometrics.pressure_at_altitude(altitude_test)
742
+ print(f"Calculated pressure at {altitude_test}m: {p_calc_alt:.0f} Pa (matches: {abs(p_calc_alt - props_altitude[\"atmospheric_pressure_pa\"]) < 1e-3})")
743
+
744
+ # Test air mixing
745
+ print("\n--- Air Mixing Test ---")
746
+ stream_a = {'flow_rate': 1.0, 't_db': 30.0, 'rh': 60.0} # m³/s, °C, %
747
+ stream_b = {'flow_rate': 0.5, 't_db': 15.0, 'w': 0.005} # m³/s, °C, kg/kg
748
+ p_mix = 100000.0 # Pa
749
+
750
+ print(f"Stream A: {stream_a}")
751
+ print(f"Stream B: {stream_b}")
752
+ print(f"Mixing at Pressure: {p_mix} Pa")
753
+
754
+ try:
755
+ mixed_props = Psychrometrics.mix_air_streams(stream_a, stream_b, p_atm=p_mix)
756
+ print("\nMixed Stream Properties:")
757
+ for key, value in mixed_props.items():
758
+ print(f"{key}: {value:.6f}")
759
+ except ValueError as e:
760
+ print(f"\nError during mixing calculation: {e}")
761
+
762
+ # Test edge cases
763
+ print("\n--- Edge Case Tests ---")
764
+ try:
765
+ print(f"Dew point at 5°C, 100% RH: {Psychrometrics.dew_point_temperature(t_db=5.0, rh=100.0):.3f}°C")
766
+ print(f"Dew point at -10°C, 80% RH: {Psychrometrics.dew_point_temperature(t_db=-10.0, rh=80.0):.3f}°C")
767
+ print(f"Wet bulb at 30°C, 100% RH: {Psychrometrics.wet_bulb_temperature(t_db=30.0, rh=100.0):.3f}°C")
768
+ print(f"Wet bulb at -5°C, 50% RH: {Psychrometrics.wet_bulb_temperature(t_db=-5.0, rh=50.0):.3f}°C")
769
+ # Test high temp / high humidity
770
+ props_hot_humid = Psychrometrics.moist_air_properties(t_db=50, rh=90, p_atm=101325)
771
+ print(f"Properties at 50°C, 90% RH: W={props_hot_humid[\"humidity_ratio_kg_kg\"]:.6f}, H={props_hot_humid[\"enthalpy_j_kg\"]:.0f}")
772
+ except ValueError as e:
773
+ print(f"Error during edge case test: {e}")
774
+
775
+