Coverage for src/gwtransport/_validation.py: 100%

35 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-07 21:17 +0000

1""" 

2Composable input-validation atoms for transport-module entry points. 

3 

4The public ``infiltration_to_extraction`` / ``extraction_to_infiltration`` functions in 

5:mod:`gwtransport.advection`, :mod:`gwtransport.diffusion`, and 

6:mod:`gwtransport.deposition` share a small set of input-validation invariants 

7(bin-edge parity, NaN-free arrays, non-negative flow, positive physical 

8parameters). The atoms here factor those invariants once so that each module 

9exposes a single ``_validate_<module>_inputs`` wrapper composing the atoms with 

10module-specific error-message wording and ordering. 

11 

12Each atom is keyword-only (except for the array under test) and raises 

13``ValueError`` with a default message; the optional ``message`` keyword lets the 

14module wrapper preserve the historical wording verbatim so that downstream 

15``pytest.raises(..., match=...)`` tests keep passing without modification. 

16 

17This module has no public API; importers are the transport modules themselves 

18plus ``tests/src/test_validation.py``. 

19""" 

20 

21from __future__ import annotations 

22 

23import numpy as np 

24import numpy.typing as npt 

25import pandas as pd # noqa: TC002 -- pandas is a hard runtime dependency; import unconditionally 

26 

27 

28def _validate_tedges_parity( 

29 tedges: pd.DatetimeIndex, 

30 values: npt.ArrayLike, 

31 *, 

32 tedges_name: str, 

33 values_name: str, 

34) -> None: 

35 """Validate bin-edge parity: ``len(tedges) == len(values) + 1``. 

36 

37 Parameters 

38 ---------- 

39 tedges : DatetimeIndex 

40 Bin edges (length ``n + 1``). 

41 values : array-like 

42 Bin-constant values (length ``n``). 

43 tedges_name, values_name : str 

44 Names used in the error message, e.g. ``"tedges"`` and ``"cin"``. 

45 

46 Raises 

47 ------ 

48 ValueError 

49 If ``len(tedges) != len(values) + 1``. 

50 """ 

51 n_values = np.asarray(values).shape[0] 

52 if len(tedges) != n_values + 1: 

53 msg = f"{tedges_name} must have one more element than {values_name}" 

54 raise ValueError(msg) 

55 

56 

57def _validate_no_nan( 

58 arr: npt.ArrayLike, 

59 *, 

60 name: str, 

61 message: str | None = None, 

62) -> None: 

63 """Validate that ``arr`` contains no NaN values. 

64 

65 Parameters 

66 ---------- 

67 arr : array-like 

68 Array to check. 

69 name : str 

70 Variable name used in the default error message. 

71 message : str, optional 

72 Override the default ``"{name} contains NaN values, which are not allowed"`` 

73 wording. Used by module wrappers that need to preserve historical strings 

74 pinned by ``pytest.raises(..., match=...)`` tests. 

75 

76 Raises 

77 ------ 

78 ValueError 

79 If any element of ``arr`` is NaN. 

80 """ 

81 if np.any(np.isnan(np.asarray(arr))): 

82 msg = message if message is not None else f"{name} contains NaN values, which are not allowed" 

83 raise ValueError(msg) 

84 

85 

86def _validate_non_negative_array( 

87 arr: npt.ArrayLike, 

88 *, 

89 name: str, 

90 message: str | None = None, 

91) -> None: 

92 """Validate that every element of ``arr`` is finite and non-negative (``>= 0``). 

93 

94 Zeros are allowed. The companion ``_validate_positive_array`` rejects zeros too. NaN and 

95 ``+inf`` are rejected: both pass every ``< 0`` comparison, so a bare inequality would let 

96 them slip through and poison the downstream computation. 

97 

98 Parameters 

99 ---------- 

100 arr : array-like 

101 Array to check (any shape). 

102 name : str 

103 Variable name used in the default error message. 

104 message : str, optional 

105 Override the default ``"{name} must be non-negative"`` wording. 

106 

107 Raises 

108 ------ 

109 ValueError 

110 If any element of ``arr`` is negative or non-finite (NaN or infinite). 

111 """ 

112 a = np.asarray(arr, dtype=float) 

113 if not np.all(np.isfinite(a) & (a >= 0.0)): 

114 msg = message if message is not None else f"{name} must be non-negative" 

115 raise ValueError(msg) 

116 

117 

118def _validate_positive_array( 

119 arr: npt.ArrayLike, 

120 *, 

121 name: str, 

122 message: str | None = None, 

123) -> None: 

124 """Validate that every element of ``arr`` is finite and strictly positive (``> 0``). 

125 

126 NaN and ``+inf`` are rejected: both pass every ``<= 0`` comparison, so a bare inequality 

127 would let them slip through and poison the downstream computation. 

128 

129 Parameters 

130 ---------- 

131 arr : array-like 

132 Array to check. 

133 name : str 

134 Variable name used in the default error message. 

135 message : str, optional 

136 Override the default ``"{name} must be positive"`` wording. 

137 

138 Raises 

139 ------ 

140 ValueError 

141 If any element of ``arr`` is ``<= 0`` or non-finite (NaN or infinite). 

142 """ 

143 a = np.asarray(arr, dtype=float) 

144 if np.any(~np.isfinite(a)) or np.any(a <= 0.0): 

145 msg = message if message is not None else f"{name} must be positive" 

146 raise ValueError(msg) 

147 

148 

149def _validate_positive_scalar( 

150 value: float, 

151 *, 

152 name: str, 

153 message: str | None = None, 

154) -> None: 

155 """Validate that ``value`` is finite and strictly positive (``> 0``). 

156 

157 NaN and ``+inf`` are rejected: both pass the bare ``<= 0`` comparison, so an unchecked 

158 inequality would let them slip through and poison the downstream computation (e.g. a 

159 ``+inf`` thickness silently zeroed the deposition output). 

160 

161 Parameters 

162 ---------- 

163 value : float 

164 Scalar to check. 

165 name : str 

166 Variable name used in the default error message. 

167 message : str, optional 

168 Override the default ``"{name} must be positive, got {value}"`` wording. 

169 

170 Raises 

171 ------ 

172 ValueError 

173 If ``value <= 0`` or is non-finite (NaN or infinite). 

174 """ 

175 if not np.isfinite(value) or value <= 0: 

176 msg = message if message is not None else f"{name} must be positive, got {value}" 

177 raise ValueError(msg) 

178 

179 

180def _validate_retardation_factor(value: float) -> None: 

181 """Validate that the retardation factor is ``>= 1`` (anti-retardation is unphysical). 

182 

183 The check is written as ``not value >= 1.0`` rather than ``value < 1.0`` so that NaN is 

184 rejected too: ``NaN >= 1.0`` is False, so the bare ``< 1.0`` form would let NaN pass and 

185 silently propagate an all-NaN transport output. 

186 

187 Parameters 

188 ---------- 

189 value : float 

190 Retardation factor to check. 

191 

192 Raises 

193 ------ 

194 ValueError 

195 If ``value`` is NaN or ``value < 1.0``. 

196 """ 

197 if not value >= 1.0: 

198 msg = "retardation_factor must be >= 1.0" 

199 raise ValueError(msg) 

200 

201 

202def _validate_scalar_or_matching_length( 

203 arr: npt.ArrayLike, 

204 *, 

205 name: str, 

206 expected_len: int, 

207 ref_name: str, 

208) -> None: 

209 """Validate length: ``len(arr) == expected_len``. 

210 

211 Intended for inputs that the caller accepts as either a scalar or an 

212 array matching some reference length; the caller is expected to have 

213 broadcast size-1 arrays to ``expected_len`` *before* calling this atom. 

214 The error message refers to the user-facing scalar-or-matching contract. 

215 

216 Parameters 

217 ---------- 

218 arr : array-like 

219 Array to check (already broadcast from scalar form by the caller). 

220 name : str 

221 Variable name used in the error message. 

222 expected_len : int 

223 Required length. 

224 ref_name : str 

225 Name of the reference array whose length is the contract. 

226 

227 Raises 

228 ------ 

229 ValueError 

230 If ``len(arr) != expected_len``. 

231 """ 

232 if np.asarray(arr).shape[0] != expected_len: 

233 msg = f"{name} must be a scalar or have same length as {ref_name}" 

234 raise ValueError(msg)