Coverage for pybeepop/beepop/parameters.py: 92%

50 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-19 20:58 +0000

1"""Type and range specifications for numeric BeePop+ parameters. 

2 

3Every numeric parameter accepted by VarroaPopSession.update_colony_parameters() is 

4listed in PARAMETER_SPECS with the range it accepts. Values outside that range are 

5rejected with an error rather than being passed on to the model. 

6 

7Bounds are written the way they appear in the Min and Max columns of 

8BeePop_exposed_parameters.csv: a bare number is inclusive, a number prefixed with 

9'>' or '<' is exclusive, and an empty string means unbounded on that side. The two 

10must agree; tests/test_parameter_validation.py compares them row by row. 

11""" 

12 

13import math 

14from dataclasses import dataclass 

15 

16INTEGER = "Integer" 

17FLOAT = "Float" 

18 

19 

20@dataclass(frozen=True) 

21class ParameterSpec: 

22 """The accepted type and range for one parameter.""" 

23 

24 kind: str 

25 minimum: str = "" 

26 maximum: str = "" 

27 

28 

29def _parse_bound(text: str) -> tuple[float, bool] | None: 

30 """Return ``(value, exclusive)`` for a bound string, or None if unbounded.""" 

31 text = text.strip() 

32 if not text or text == "N/A": 

33 return None 

34 if text[0] in "<>": 

35 return float(text[1:]), True 

36 return float(text), False 

37 

38 

39PARAMETER_SPECS: dict[str, ParameterSpec] = { 

40 # Colony initial conditions 

41 "icdroneadults": ParameterSpec(INTEGER, "0"), 

42 "icworkeradults": ParameterSpec(INTEGER, "0"), 

43 "icdronebrood": ParameterSpec(INTEGER, "0"), 

44 "icworkerbrood": ParameterSpec(INTEGER, "0"), 

45 "icdronelarvae": ParameterSpec(INTEGER, "0"), 

46 "icworkerlarvae": ParameterSpec(INTEGER, "0"), 

47 "icdroneeggs": ParameterSpec(INTEGER, "0"), 

48 "icworkereggs": ParameterSpec(INTEGER, "0"), 

49 "icqueenstrength": ParameterSpec(FLOAT, "1", "5"), 

50 # Biological range from the published model. 

51 "icforagerlifespan": ParameterSpec(INTEGER, "4", "16"), 

52 # Varroa initial infestation 

53 "icdroneadultinfest": ParameterSpec(FLOAT, "0", "100"), 

54 "icdronebroodinfest": ParameterSpec(FLOAT, "0", "100"), 

55 "icdronemiteoffspring": ParameterSpec(FLOAT, "0"), 

56 "icdronemitesurvivorship": ParameterSpec(FLOAT, "0", "100"), 

57 "icworkeradultinfest": ParameterSpec(FLOAT, "0", "100"), 

58 "icworkerbroodinfest": ParameterSpec(FLOAT, "0", "100"), 

59 "icworkermiteoffspring": ParameterSpec(FLOAT, "0"), 

60 "icworkermitesurvivorship": ParameterSpec(FLOAT, "0", "100"), 

61 # Varroa immigration 

62 "pctimmmitesresistant": ParameterSpec(FLOAT, "0", "100"), 

63 "totalimmmites": ParameterSpec(INTEGER, "0"), 

64 # Varroa treatment 

65 "initmitepctresistant": ParameterSpec(FLOAT, "0", "100"), 

66 # Re-queening 

67 "rqegglaydelay": ParameterSpec(INTEGER, "0"), 

68 "rqqueenstrength": ParameterSpec(FLOAT, "1", "5"), 

69 # Toxicity. dose_response() returns zero mortality outside these bounds. 

70 "aiadultslope": ParameterSpec(FLOAT, "0", "<20"), 

71 "aiadultld50": ParameterSpec(FLOAT, ">0"), 

72 "aiadultslopecontact": ParameterSpec(FLOAT, "0", "<20"), 

73 "aiadultld50contact": ParameterSpec(FLOAT, ">0"), 

74 "ailarvaslope": ParameterSpec(FLOAT, "0", "<20"), 

75 "ailarvald50": ParameterSpec(FLOAT, ">0"), 

76 "aikow": ParameterSpec(FLOAT, ">0"), 

77 "aikoc": ParameterSpec(FLOAT, ">0"), 

78 "aihalflife": ParameterSpec(FLOAT, ">0"), 

79 "aicontactfactor": ParameterSpec(FLOAT, "0"), 

80 # Daily consumption 

81 "cl4pollen": ParameterSpec(FLOAT, "0"), 

82 "cl4nectar": ParameterSpec(FLOAT, "0"), 

83 "cl5pollen": ParameterSpec(FLOAT, "0"), 

84 "cl5nectar": ParameterSpec(FLOAT, "0"), 

85 "cldpollen": ParameterSpec(FLOAT, "0"), 

86 "cldnectar": ParameterSpec(FLOAT, "0"), 

87 "ca13pollen": ParameterSpec(FLOAT, "0"), 

88 "ca13nectar": ParameterSpec(FLOAT, "0"), 

89 "ca410pollen": ParameterSpec(FLOAT, "0"), 

90 "ca410nectar": ParameterSpec(FLOAT, "0"), 

91 "ca1120pollen": ParameterSpec(FLOAT, "0"), 

92 "ca1120nectar": ParameterSpec(FLOAT, "0"), 

93 "cadpollen": ParameterSpec(FLOAT, "0"), 

94 "cadnectar": ParameterSpec(FLOAT, "0"), 

95 "cforagerpollen": ParameterSpec(FLOAT, "0"), 

96 "cforagernectar": ParameterSpec(FLOAT, "0"), 

97 # Foraging 

98 "ipollentrips": ParameterSpec(INTEGER, "0"), 

99 "inectartrips": ParameterSpec(INTEGER, "0"), 

100 "ipercentnectarforagers": ParameterSpec(FLOAT, "0", "100"), 

101 "ipollenload": ParameterSpec(FLOAT, "0"), 

102 "inectarload": ParameterSpec(FLOAT, "0"), 

103 # Pesticide application 

104 "eapprate": ParameterSpec(FLOAT, "0"), 

105 "esoiltheta": ParameterSpec(FLOAT, "0", "1"), 

106 "esoilp": ParameterSpec(FLOAT, ">0"), 

107 "esoilfoc": ParameterSpec(FLOAT, ">0", "1"), 

108 "esoilconcentration": ParameterSpec(FLOAT, "0"), 

109 "eseedapprate": ParameterSpec(FLOAT, "0"), 

110 # Colony resources 

111 "initcolnectar": ParameterSpec(FLOAT, "0"), 

112 "initcolpollen": ParameterSpec(FLOAT, "0"), 

113 "maxcolnectar": ParameterSpec(FLOAT, "0"), 

114 "maxcolpollen": ParameterSpec(FLOAT, "0"), 

115 "suppollenamount": ParameterSpec(FLOAT, "0"), 

116 "supnectaramount": ParameterSpec(FLOAT, "0"), 

117 # Other 

118 "foragermaxprop": ParameterSpec(FLOAT, "0", "1"), 

119 # Day length is computed as at +/-65 degrees beyond that latitude. 

120 "latitude": ParameterSpec(FLOAT, "-90", "90"), 

121} 

122 

123 

124def _describe_range(spec: ParameterSpec) -> str: 

125 low, high = _parse_bound(spec.minimum), _parse_bound(spec.maximum) 

126 if low is not None and high is not None: 

127 lo_op = ">" if low[1] else ">=" 

128 hi_op = "<" if high[1] else "<=" 

129 return f"{lo_op} {low[0]:g} and {hi_op} {high[0]:g}" 

130 if low is not None: 

131 return f"{'>' if low[1] else '>='} {low[0]:g}" 

132 if high is not None: 

133 return f"{'<' if high[1] else '<='} {high[0]:g}" 

134 return "" 

135 

136 

137def validate_parameter( 

138 name: str, value: str, display_name: str | None = None 

139) -> tuple[bool, str, str | None]: 

140 """Check one parameter value against its spec. 

141 

142 Args: 

143 name: Parameter name, lowercased. 

144 value: Raw parameter value as a string. 

145 display_name: Name to use in error messages. Defaults to ``name``. 

146 

147 Returns: 

148 ``(ok, normalized_value, error)``. When ``ok`` is False, ``error`` explains why 

149 the value was rejected. Integer parameters are normalized to a whole-number 

150 string so downstream parsing accepts them. 

151 """ 

152 spec = PARAMETER_SPECS.get(name) 

153 if spec is None: 

154 return True, value, None 

155 

156 label = display_name if display_name is not None else name 

157 

158 try: 

159 number = float(value) 

160 except (TypeError, ValueError): 

161 return False, value, f"{label} must be a number, got '{value}'." 

162 

163 if math.isnan(number) or math.isinf(number): 

164 return False, value, f"{label} must be a finite number, got '{value}'." 

165 

166 low = _parse_bound(spec.minimum) 

167 high = _parse_bound(spec.maximum) 

168 below = low is not None and (number < low[0] or (low[1] and number == low[0])) 

169 above = high is not None and (number > high[0] or (high[1] and number == high[0])) 

170 if below or above: 

171 return ( 

172 False, 

173 value, 

174 f"{label} is out of range: {value}. Must be {_describe_range(spec)}.", 

175 ) 

176 

177 if spec.kind == INTEGER: 

178 if number != int(number): 

179 return False, value, f"{label} must be a whole number, got '{value}'." 

180 return True, str(int(number)), None 

181 

182 return True, value, None