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
« 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.
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.
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"""
13import math
14from dataclasses import dataclass
16INTEGER = "Integer"
17FLOAT = "Float"
20@dataclass(frozen=True)
21class ParameterSpec:
22 """The accepted type and range for one parameter."""
24 kind: str
25 minimum: str = ""
26 maximum: str = ""
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
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}
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 ""
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.
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``.
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
156 label = display_name if display_name is not None else name
158 try:
159 number = float(value)
160 except (TypeError, ValueError):
161 return False, value, f"{label} must be a number, got '{value}'."
163 if math.isnan(number) or math.isinf(number):
164 return False, value, f"{label} must be a finite number, got '{value}'."
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 )
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
182 return True, value, None