1+ from __future__ import annotations
2+
13import pytest
24import os
35import numpy as np
46from scipy .optimize import fsolve
7+ import warnings
8+ from typing import Tuple , List , Generator
59
610from PySDM import Formulae
711from PySDM .physics import si
1014
1115
1216class GroundTruthLoader :
13- def __init__ (self , groundtruth_dir_path , n_samples = 2 , random_seed = 2137 ):
17+ def __init__ (
18+ self , groundtruth_dir_path : str , n_samples : int = 2 , random_seed : int = 2137
19+ ):
1420 self .dir_path = groundtruth_dir_path
1521 self .RHs = None
1622 self .r0grid = None
1723 self .m_frac_evap = None
18- self .n_samples = n_samples # Number of random samples to test
19- np .random .seed (random_seed ) # reproducible random samples during debugging
24+ self .n_samples = n_samples
25+ np .random .seed (random_seed )
2026
2127 def __enter__ (self ):
2228 try :
@@ -25,16 +31,64 @@ def __enter__(self):
2531 self .m_frac_evap = np .load (os .path .join (self .dir_path , "m_frac_evap.npy" ))
2632 return self
2733 except FileNotFoundError as e :
28- print (f"Error loading ground truth files: { e } " )
29- raise
34+ pytest .fail (f"Error loading ground truth files: { e } " )
3035 except Exception as e :
31- print (f"An unexpected error occurred while loading ground truth data: { e } " )
32- raise
36+ pytest .fail (f"Unexpected error loading ground truth data: { e } " )
3337
3438 def __exit__ (self , exc_type , exc_val , exc_tb ):
3539 pass
3640
3741
42+ @pytest .fixture (scope = "module" )
43+ def ground_truth_data (request ) -> Generator [GroundTruthLoader , None , None ]:
44+ current_dir = os .path .dirname (os .path .abspath (request .fspath ))
45+ groundtruth_dir = os .path .abspath (os .path .join (current_dir , "ground_truth" ))
46+ if not os .path .isdir (groundtruth_dir ):
47+ pytest .fail (f"Groundtruth directory not found at { groundtruth_dir } " )
48+ with GroundTruthLoader (groundtruth_dir ) as gt :
49+ yield gt
50+
51+
52+ @pytest .fixture
53+ def ground_truth_sample (ground_truth_data : GroundTruthLoader ) -> List [dict ]:
54+ gt = ground_truth_data
55+ n_rh_values = len (gt .RHs )
56+ n_radius_values = gt .r0grid .shape [1 ]
57+ total_points = n_rh_values * n_radius_values
58+ n_samples = min (gt .n_samples , total_points )
59+ if n_samples == 0 :
60+ pytest .skip ("No data points available to sample." )
61+ all_indices = np .array (
62+ [(i , j ) for i in range (n_rh_values ) for j in range (n_radius_values )]
63+ )
64+ sampled_indices_flat = np .random .choice (len (all_indices ), n_samples , replace = False )
65+ sampled_ij_pairs = all_indices [sampled_indices_flat ]
66+ return [
67+ {
68+ "rh" : gt .RHs [i_rh ],
69+ "r_m" : gt .r0grid [0 , j_r ],
70+ "expected_m_frac_evap" : gt .m_frac_evap [i_rh , j_r ],
71+ "i_rh" : i_rh ,
72+ "j_r" : j_r ,
73+ }
74+ for i_rh , j_r in sampled_ij_pairs
75+ ]
76+
77+
78+ @pytest .fixture (scope = "module" )
79+ def static_arrays () -> Tuple [np .ndarray , np .ndarray , np .ndarray , object ]:
80+ formulae = Formulae (
81+ ventilation = "PruppacherAndRasmussen1979" ,
82+ saturation_vapour_pressure = "AugustRocheMagnus" ,
83+ diffusion_coordinate = "WaterMassLogarithm" ,
84+ )
85+ radius_array = np .logspace (- 4.5 , - 2.5 , 50 ) * si .m
86+ RH_array = np .linspace (0.25 , 0.99 , 50 )
87+ output_matrix = np .full ((len (RH_array ), len (radius_array )), np .nan )
88+ const = formulae .constants
89+ return radius_array , RH_array , output_matrix , const
90+
91+
3892class TestNPYComparison :
3993 @staticmethod
4094 def _mix (dry_prop , vap_prop , ratio ):
@@ -44,15 +98,11 @@ def _calculate_cloud_properties(
4498 self , planet : EarthLike , surface_RH : float , formulae_instance : Formulae
4599 ):
46100 const = formulae_instance .constants
47-
48101 planet .RH_zref = surface_RH
49-
50102 pvs_stp = formulae_instance .saturation_vapour_pressure .pvs_water (planet .T_STP )
51-
52103 initial_water_vapour_mixing_ratio = const .eps / (
53104 planet .p_STP / planet .RH_zref / pvs_stp - 1
54105 )
55-
56106 R_air_mix = self ._mix (const .Rd , const .Rv , initial_water_vapour_mixing_ratio )
57107 cp_mix = self ._mix (const .c_pd , const .c_pv , initial_water_vapour_mixing_ratio )
58108
@@ -78,154 +128,113 @@ def solve_Tcloud(T_candidate):
78128 )
79129 return initial_water_vapour_mixing_ratio , Tcloud , Zcloud , pcloud
80130
81- def test_figure_2_replication_accuracy (self ):
82- current_dir = os .path .dirname (os .path .abspath (__file__ ))
83- groundtruth_dir = os .path .abspath (os .path .join (current_dir , "ground_truth" ))
84- if not os .path .isdir (groundtruth_dir ):
85- pytest .fail (f"Groundtruth directory not found at { groundtruth_dir } " )
86-
131+ def test_figure_2_replication_accuracy (self , ground_truth_sample , static_arrays ):
87132 formulae = Formulae (
88133 ventilation = "PruppacherAndRasmussen1979" ,
89134 saturation_vapour_pressure = "AugustRocheMagnus" ,
90135 diffusion_coordinate = "WaterMassLogarithm" ,
91136 )
92-
93- with GroundTruthLoader (groundtruth_dir ) as gt :
94- if gt .RHs is None or gt .r0grid is None or gt .m_frac_evap is None :
95- pytest .fail ("Ground truth data (.npy files) not loaded properly." )
96-
97- n_rh_values = len (gt .RHs )
98- n_radius_values = gt .r0grid .shape [1 ]
99-
100- total_points = n_rh_values * n_radius_values
101- n_samples = min (gt .n_samples , total_points )
102-
103- if n_samples == 0 :
104- pytest .skip ("No data points available to sample." )
105-
106- all_indices = np .array (
107- [(i , j ) for i in range (n_rh_values ) for j in range (n_radius_values )]
137+ for sample in ground_truth_sample :
138+ planet = EarthLike ()
139+ rh = sample ["rh" ]
140+ r_m = sample ["r_m" ]
141+ expected = sample ["expected_m_frac_evap" ]
142+ i_rh = sample ["i_rh" ]
143+ j_r = sample ["j_r" ]
144+ try :
145+ iwvmr , Tcloud , Zcloud , pcloud = self ._calculate_cloud_properties (
146+ planet , rh , formulae
147+ )
148+ simulated = self .calc_simulated_m_frac_evap_point (
149+ planet ,
150+ formulae ,
151+ i_rh ,
152+ j_r ,
153+ rh ,
154+ r_m ,
155+ expected ,
156+ iwvmr ,
157+ Tcloud ,
158+ Zcloud ,
159+ pcloud ,
160+ )
161+ except Exception as e :
162+ pytest .fail (
163+ f"Error in _calculate_cloud_properties for RH={ rh } (sample idx { i_rh } ,{ j_r } ): { e } ."
164+ )
165+ error_context = (
166+ f"Sample (RH_idx={ i_rh } , R_idx={ j_r } ), RH={ rh :.4f} , R_m={ r_m :.3e} . "
167+ f"Expected: { expected } , Got: { simulated } "
108168 )
109-
110- sampled_indices_flat = np .random .choice (len (all_indices ), n_samples , replace = False )
111- sampled_ij_pairs = all_indices [sampled_indices_flat ]
112-
113- for i_rh , j_r in sampled_ij_pairs :
114- current_planet_state = EarthLike ()
115-
116- current_rh = gt .RHs [i_rh ]
117- current_r_m = gt .r0grid [0 , j_r ]
118- expected_m_frac_evap = gt .m_frac_evap [i_rh , j_r ]
119-
120- try :
121- iwvmr , Tcloud , Zcloud , pcloud = self ._calculate_cloud_properties (
122- current_planet_state , current_rh , formulae
123- )
124- simulated_m_frac_evap_point = self .calc_simulated_m_frac_evap_point (
125- current_planet_state ,
126- formulae ,
127- i_rh ,
128- j_r ,
129- current_rh ,
130- current_r_m ,
131- expected_m_frac_evap ,
132- iwvmr ,
133- Tcloud ,
134- Zcloud ,
135- pcloud ,
136- )
137- except Exception as e :
138- print (
139- f"Warning: Error in _calculate_cloud_properties for RH={ current_rh } (sample idx { i_rh } ,{ j_r } ): { e } ."
140- )
141-
142- error_context = (
143- f"Sample (RH_idx={ i_rh } , R_idx={ j_r } ), "
144- f"RH={ current_rh :.4f} , R_m={ current_r_m :.3e} . "
145- f"Expected: { expected_m_frac_evap } , Got: { simulated_m_frac_evap_point } "
169+ if np .isnan (expected ):
170+ assert np .isnan (
171+ simulated
172+ ), f"NaN Mismatch. { error_context } (Expected NaN, got non-NaN)"
173+ else :
174+ assert not np .isnan (
175+ simulated
176+ ), f"NaN Mismatch. { error_context } (Expected non-NaN, got NaN)"
177+ np .testing .assert_allclose (
178+ simulated ,
179+ expected ,
180+ rtol = 1e-1 ,
181+ atol = 1e-1 ,
182+ err_msg = f"Value Mismatch. { error_context } " ,
146183 )
147184
148- if np .isnan (expected_m_frac_evap ):
149- assert np .isnan (
150- simulated_m_frac_evap_point
151- ), f"NaN Mismatch. { error_context } (Expected NaN, got non-NaN)"
152- else :
153- assert not np .isnan (
154- simulated_m_frac_evap_point
155- ), f"NaN Mismatch. { error_context } (Expected non-NaN, got NaN)"
156- np .testing .assert_allclose (
157- simulated_m_frac_evap_point ,
158- expected_m_frac_evap ,
159- rtol = 1e-1 , # Relative tolerance
160- atol = 1e-1 , # Absolute tolerance
161- err_msg = f"Value Mismatch. { error_context } " ,
162- )
163-
164185 def calc_simulated_m_frac_evap_point (
165186 self ,
166- current_planet_state ,
187+ planet ,
167188 formulae ,
168189 i_rh ,
169190 j_r ,
170- current_rh ,
171- current_r_m ,
172- expected_m_frac_evap ,
191+ rh ,
192+ r_m ,
193+ expected ,
173194 iwvmr ,
174195 Tcloud ,
175196 Zcloud ,
176197 pcloud ,
177198 ):
178-
179- simulated_m_frac_evap_point = np .nan
180-
181- if np .isnan (current_r_m ) or current_r_m <= 0 :
182- print (f"Warning: Invalid radius current_r_m={ current_r_m } for sample idx { i_rh } ,{ j_r } ." )
183- else :
184- settings = Settings (
185- planet = current_planet_state ,
186- r_wet = current_r_m ,
187- mass_of_dry_air = 1e5 * si .kg ,
188- initial_water_vapour_mixing_ratio = iwvmr ,
189- pcloud = pcloud ,
190- Zcloud = Zcloud ,
191- Tcloud = Tcloud ,
192- formulae = formulae ,
193- )
194- simulation = Simulation (settings )
195-
196- try :
197- output = simulation .run ()
198- if (
199- output
200- and "r" in output
201- and len (output ["r" ]) > 0
202- and "z" in output
203- and len (output ["z" ]) > 0
204- ):
205- final_radius_um = output ["r" ][- 1 ]
206- if np .isnan (final_radius_um ) or final_radius_um < 0 :
207- final_radius_m = final_radius_um * 1e-6
208- if final_radius_m < 0 : # Non-physical radius
209- simulated_m_frac_evap_point = 1.0 # 1.0 means fully evaporated
210- else :
211- simulated_m_frac_evap_point = np .nan
212- else :
213- final_radius_m = final_radius_um * 1e-6
214- if current_r_m == 0 :
215- frac_evap = 1.0
216- else :
217- frac_evap = 1.0 - (final_radius_m / current_r_m ) ** 3
218- frac_evap = np .clip (frac_evap , 0.0 , 1.0 )
219- simulated_m_frac_evap_point = frac_evap
220- else :
221- simulated_m_frac_evap_point = np .nan
222- except Exception as e :
223- print (
224- f"Warning: Simulation run failed for RH={ current_rh } , r={ current_r_m } (sample idx { i_rh } ,{ j_r } ): { e } ."
225- )
226- if np .isclose (expected_m_frac_evap , 1.0 , atol = 1e-6 ):
227- simulated_m_frac_evap_point = 1.0
199+ if np .isnan (r_m ) or r_m <= 0 :
200+ pytest .fail (f"Invalid radius r_m={ r_m } for sample idx { i_rh } ,{ j_r } ." )
201+ settings = Settings (
202+ planet = planet ,
203+ r_wet = r_m ,
204+ mass_of_dry_air = 1e5 * si .kg ,
205+ initial_water_vapour_mixing_ratio = iwvmr ,
206+ pcloud = pcloud ,
207+ Zcloud = Zcloud ,
208+ Tcloud = Tcloud ,
209+ formulae = formulae ,
210+ )
211+ simulation = Simulation (settings )
212+ try :
213+ output = simulation .run ()
214+ if (
215+ output
216+ and "r" in output
217+ and len (output ["r" ]) > 0
218+ and "z" in output
219+ and len (output ["z" ]) > 0
220+ ):
221+ final_radius_um = output ["r" ][- 1 ]
222+ if np .isnan (final_radius_um ) or final_radius_um < 0 :
223+ final_radius_m = final_radius_um * 1e-6
224+ if final_radius_m < 0 :
225+ return 1.0
226+ return np .nan
227+ final_radius_m = final_radius_um * 1e-6
228+ if r_m == 0 :
229+ frac_evap = 1.0
228230 else :
229- simulated_m_frac_evap_point = np .nan
230-
231- return simulated_m_frac_evap_point
231+ frac_evap = 1.0 - (final_radius_m / r_m ) ** 3
232+ return np .clip (frac_evap , 0.0 , 1.0 )
233+ return np .nan
234+ except Exception as e :
235+ warnings .warn (
236+ f"Simulation run failed for RH={ rh :.4f} , r={ r_m :.3e} (sample idx { i_rh } ,{ j_r } ): { type (e ).__name__ } : { e } "
237+ )
238+ if np .isclose (expected , 1.0 , atol = 1e-6 ):
239+ return 1.0
240+ return np .nan
0 commit comments