Coverage for pybeepop/beepop/weatherevents.py: 64%

256 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-30 13:34 +0000

1"""BeePop+ Weather Events Module. 

2 

3This module contains classes for managing weather data and environmental conditions 

4that drive bee colony dynamics. Temperature, wind and rainfall thresholds determine 

5foraging conditions, while daylight hours affect egg laying and foraging patterns. 

6 

7Classes: 

8 Event: Individual weather record with temperature, precipitation, and daylight data 

9 WeatherEvents: Collection manager for weather data with interpolation and analysis 

10""" 

11 

12from datetime import datetime 

13import math 

14from pybeepop.beepop.globaloptions import GlobalOptions 

15from pybeepop.beepop.coldstoragesimulator import ColdStorageSimulator 

16 

17 

18def count_chars(in_str, test_char): 

19 """ 

20 Port of free function CountChars(CString instg, TCHAR testchar). 

21 Returns the number of occurrences of test_char in in_str. 

22 """ 

23 return in_str.count(test_char) 

24 

25 

26class Event: 

27 """Individual weather event representing daily environmental conditions. 

28 

29 Represents a single day's weather data including temperature, precipitation, 

30 wind conditions. 

31 

32 Attributes: 

33 time (datetime): Date and time for this weather event 

34 temp (float): Average daily temperature in Celsius 

35 max_temp (float): Maximum daily temperature in Celsius 

36 min_temp (float): Minimum daily temperature in Celsius 

37 windspeed (float): Wind speed in m/s 

38 rainfall (float): Precipitation amount in mm 

39 daylight_hours (float): Calculated daylight duration for this date/location 

40 forage_day (bool): Whether conditions allow foraging activity 

41 forage_inc (float): Foraging increment factor based on conditions 

42 """ 

43 

44 def __init__( 

45 self, 

46 time=None, 

47 temp=0.0, 

48 max_temp=0.0, 

49 min_temp=0.0, 

50 windspeed=0.0, 

51 rainfall=0.0, 

52 daylight_hours=0.0, 

53 ): 

54 self.time = time or datetime.now() 

55 self.temp = temp 

56 self.max_temp = max_temp 

57 self.min_temp = min_temp 

58 self.windspeed = windspeed 

59 self.rainfall = rainfall 

60 self.daylight_hours = daylight_hours 

61 self.forage_day = True 

62 self.forage_inc = 0.0 

63 

64 @classmethod 

65 def copy_from(cls, other_event): 

66 """ 

67 Port of CEvent copy constructor CEvent(CEvent& event). 

68 Creates a new Event as a copy of another Event. 

69 """ 

70 new_event = cls() 

71 new_event.time = other_event.time 

72 new_event.temp = other_event.temp 

73 new_event.max_temp = other_event.max_temp 

74 new_event.min_temp = other_event.min_temp 

75 new_event.windspeed = other_event.windspeed 

76 new_event.rainfall = other_event.rainfall 

77 new_event.daylight_hours = other_event.daylight_hours 

78 new_event.forage_day = other_event.forage_day 

79 new_event.forage_inc = other_event.forage_inc 

80 return new_event 

81 

82 def to_string(self): 

83 return ( 

84 f"Date: {self.time.strftime('%m/%d/%Y')}\n Temp: {self.temp:.2f}\n MaxTemp: {self.max_temp:.2f}\n " 

85 f"MinTemp: {self.min_temp:.2f}\n Rainfall: {self.rainfall:.2f}\n DaylightHours: {self.daylight_hours:.2f}\n" 

86 ) 

87 

88 def update_forage_attribute_for_event(self, latitude, windspeed=None): 

89 """ 

90 Port of CEvent::UpdateForageAttributeForEvent(double Latitude, double windSpeed). 

91 Compute the Forage and ForageInc attributes for this event. 

92 Forage day logic uses temperature, windspeed, and rainfall thresholds from global options. 

93 """ 

94 windspeed = windspeed if windspeed is not None else self.get_windspeed() 

95 global_options = GlobalOptions.get() 

96 

97 if global_options.should_forage_day_election_based_on_temperatures: 

98 self.forage_day = ( 

99 self.get_max_temp() > 12.0 

100 and windspeed <= global_options.windspeed_threshold 

101 and self.get_max_temp() <= 43.33 

102 and self.get_rainfall() <= global_options.rainfall_threshold 

103 ) 

104 else: 

105 self.forage_day = ( 

106 windspeed <= global_options.windspeed_threshold 

107 and self.get_rainfall() <= global_options.rainfall_threshold 

108 ) 

109 self.set_hourly_forage_inc(latitude) 

110 

111 # Keep the old method name for backward compatibility 

112 def update_forage_attribute(self, latitude, windspeed=None, global_options=None): 

113 """Legacy method name for backward compatibility.""" 

114 return self.update_forage_attribute_for_event(latitude, windspeed) 

115 

116 def set_forage(self, forage): 

117 self.forage_day = forage 

118 

119 def is_forage_day(self): 

120 """ 

121 Check if this is a forage day (with cold storage override support if needed). 

122 Port of CEvent::IsForageDay(). 

123 """ 

124 forage_day = self.forage_day 

125 cold_storage = ColdStorageSimulator.get() 

126 if cold_storage.is_enabled(): 

127 forage_day = cold_storage.is_forage_day(self) 

128 return forage_day 

129 

130 def get_daylight_hours(self): 

131 return self.daylight_hours 

132 

133 def get_forage_inc(self): 

134 """ 

135 Get forage increment (with cold storage override support if needed). 

136 Port of CEvent::GetForageInc(). 

137 """ 

138 forage_inc = self.forage_inc 

139 cold_storage = ColdStorageSimulator.get() 

140 if cold_storage.is_enabled(): 

141 forage_inc = cold_storage.get_forage_inc(self) 

142 return forage_inc 

143 

144 def set_hourly_forage_inc(self, latitude): 

145 """ 

146 Calculate the daylight hours for the longest day of the year at this latitude (solstice). 

147 Set forage_inc as a proportion of flying daylight hours to solstice daylight hours. 

148 """ 

149 solstice = self.calc_daylight_from_latitude_doy(latitude, 182) + 1 

150 if solstice <= 0: 

151 self.set_forage_inc(0) 

152 return 

153 # Note: C++ tm_yday is 0-based, Python tm_yday is 1-based. Subtract 1 to match C++. 

154 todaylength = self.calc_daylight_from_latitude_doy( 

155 latitude, self.time.timetuple().tm_yday - 1 

156 ) 

157 flightdaylength = self.calc_flight_daylight(todaylength) 

158 f_inc = min(flightdaylength / solstice, 1.0) 

159 self.set_forage_inc(f_inc) 

160 

161 def set_forage_inc(self, value): 

162 self.forage_inc = value 

163 

164 def calc_flight_daylight( 

165 self, daylength, min_temp_threshold=12.0, max_temp_threshold=43.0 

166 ): 

167 """ 

168 Returns the number of hours this day that are in daylight and within the temperature thresholds. 

169 """ 

170 sunrise = int(12 - (daylength / 2)) 

171 sunset = int(sunrise + daylength) 

172 time_tmin = sunrise - 1 

173 time_tmax = 14 

174 tmax = self.get_max_temp() # Use cold storage-aware getter 

175 tmin = self.get_min_temp() # Use cold storage-aware getter 

176 flighthours = 0 

177 for i in range(time_tmin, sunset + 1): 

178 hr_temp = (tmax + tmin) / 2 - ( 

179 (tmax - tmin) 

180 / 2 

181 * math.cos(math.pi * (i - time_tmin) / (time_tmax - time_tmin)) 

182 ) 

183 if min_temp_threshold < hr_temp < max_temp_threshold: 

184 flighthours += 1 

185 return float(flighthours) 

186 

187 def calc_daylight_from_latitude_doy(self, lat, day_of_year): 

188 """ 

189 Reference: CBM model in Ecological Modeling, volume 80 (1995) pp. 87-95. 

190 Lat is in degrees, limited to +/-65. Beyond that, day is either 24 or 0 hours. 

191 

192 NOTE: Fixed to match C++ order of operations - the C++ code has: 

193 acos(numerator / cos(lat) * cos(psi)) not acos(numerator / (cos(lat) * cos(psi))) 

194 """ 

195 if day_of_year > 366 or day_of_year < 1: 

196 return 0.0 

197 if lat < 0.0: 

198 lat = -lat 

199 day_of_year = (day_of_year + 182) % 365 

200 if lat > 65.0: 

201 lat = 65.0 

202 dl_coeff = 0.8333 

203 PI = 3.14159265358979 # Use C++ precision value 

204 theta = 0.2163108 + 2 * math.atan( 

205 0.9671396 * math.tan(0.0086 * (day_of_year - 186)) 

206 ) 

207 psi = math.asin(0.39795 * math.cos(theta)) 

208 numerator = math.sin( 

209 (dl_coeff * PI / 180) + math.sin(lat * PI / 180) * math.sin(psi) 

210 ) 

211 # Calculate daylight hours using C++ order of operations 

212 daylight_hours = 24 - (24 / PI) * math.acos( 

213 numerator / math.cos(lat * PI / 180) * math.cos(psi) 

214 ) 

215 return daylight_hours 

216 

217 def is_winter_day(self): 

218 """ 

219 Port of CEvent::IsWinterDay(). 

220 Uses cold storage-aware temperature getter. 

221 """ 

222 return self.get_temp() < 18.0 

223 

224 # Missing getters from C++ version 

225 def get_time(self): 

226 return self.time 

227 

228 def get_temp(self): 

229 """ 

230 Get temperature (with cold storage override support if needed). 

231 Port of CEvent::GetTemp(). 

232 """ 

233 event_temp = self.temp 

234 cold_storage = ColdStorageSimulator.get() 

235 if cold_storage.is_enabled(): 

236 event_temp = cold_storage.get_temp(self) 

237 return event_temp 

238 

239 def get_max_temp(self): 

240 """ 

241 Get maximum temperature (with cold storage override support if needed). 

242 Port of CEvent::GetMaxTemp(). 

243 """ 

244 event_temp = self.max_temp 

245 cold_storage = ColdStorageSimulator.get() 

246 if cold_storage.is_enabled(): 

247 event_temp = cold_storage.get_max_temp(self) 

248 return event_temp 

249 

250 def get_min_temp(self): 

251 """ 

252 Get minimum temperature (with cold storage override support if needed). 

253 Port of CEvent::GetMinTemp(). 

254 """ 

255 event_temp = self.min_temp 

256 cold_storage = ColdStorageSimulator.get() 

257 if cold_storage.is_enabled(): 

258 event_temp = cold_storage.get_min_temp(self) 

259 return event_temp 

260 

261 def get_rainfall(self): 

262 return self.rainfall 

263 

264 def get_windspeed(self): 

265 return self.windspeed 

266 

267 # Missing setters from C++ version 

268 def set_time(self, time): 

269 self.time = time 

270 

271 def set_temp(self, temp): 

272 self.temp = temp 

273 

274 def set_max_temp(self, max_temp): 

275 self.max_temp = max_temp 

276 

277 def set_min_temp(self, min_temp): 

278 self.min_temp = min_temp 

279 

280 def set_rainfall(self, rainfall): 

281 self.rainfall = rainfall 

282 

283 def set_windspeed(self, windspeed): 

284 self.windspeed = windspeed 

285 

286 def set_daylight_hours(self, hrs): 

287 self.daylight_hours = hrs 

288 

289 def get_date_stg(self, format_str="%m/%d/%Y"): 

290 """ 

291 Port of CEvent::GetDateStg(CString formatstg). 

292 Returns formatted date string. 

293 """ 

294 return self.time.strftime(format_str) 

295 

296 def parse_date(self, date_str): 

297 """ 

298 Helper method to parse date string back to datetime. 

299 Not in C++ but useful for Python implementation. 

300 """ 

301 try: 

302 return datetime.strptime(date_str, "%m/%d/%Y") 

303 except ValueError: 

304 return None 

305 

306 def calc_today_daylight_from_latitude(self, lat): 

307 """ 

308 Port of CEvent::CalcTodayDaylightFromLatitude(double Lat). 

309 Calculate daylight hours for this event's date. 

310 

311 Note: C++ tm_yday is 0-based (0-365), Python tm_yday is 1-based (1-366). 

312 We subtract 1 to match C++ behavior. But note that this introduces a bug 

313 in the calc_daylight_from_latitude_doy method on January 1st. The bug is 

314 maintained for backward compatibility with the original C++ code, but a fix 

315 would be to remove the -1 and set Jan 1st as day 1. May also need leap year 

316 handling. 

317 """ 

318 day_num = self.time.timetuple().tm_yday - 1 

319 return self.calc_daylight_from_latitude_doy(lat, day_num) 

320 

321 def set_forage_inc_with_thresholds(self, t_thresh, t_max, t_ave): 

322 """ 

323 Port of CEvent::SetForageInc(double TThresh, double TMax, double TAve). 

324 Calculates a measure of the amount of foraging for days having 

325 maximum temperatures close to the foraging threshold. 

326 """ 

327 daytime_range = t_max - t_ave 

328 

329 if daytime_range == 0: 

330 prop_threshold = 0 

331 else: 

332 prop_threshold = (t_thresh - t_ave) / daytime_range 

333 

334 if t_max < t_thresh: 

335 self.forage_inc = 0.0 

336 elif 0.968 <= prop_threshold < 1: 

337 self.forage_inc = 0.25 

338 elif 0.866 <= prop_threshold < 0.968: 

339 self.forage_inc = 0.5 

340 elif 0.660 <= prop_threshold < 0.866: 

341 self.forage_inc = 0.75 

342 else: 

343 self.forage_inc = 1.0 

344 

345 # Overloaded operators (Python equivalents) 

346 def __add__(self, other): 

347 """ 

348 Port of CEvent::operator + (CEvent event). 

349 When adding events, increment rainfall, daylight hours; reset min and 

350 max temp if necessary; If either event is not a forage day, the sum is not a 

351 forage day. 

352 """ 

353 result = Event() 

354 result.time = self.time # Keep time as beginning of event 

355 result.rainfall = self.rainfall + other.rainfall 

356 result.daylight_hours = self.daylight_hours + other.daylight_hours 

357 result.max_temp = max(self.max_temp, other.max_temp) 

358 result.min_temp = min(self.min_temp, other.min_temp) 

359 result.forage_day = self.forage_day and other.forage_day 

360 result.forage_inc = self.forage_inc + other.forage_inc 

361 # Average the other fields 

362 result.temp = (self.temp + other.temp) / 2 

363 result.windspeed = (self.windspeed + other.windspeed) / 2 

364 return result 

365 

366 def __iadd__(self, other): 

367 """ 

368 Port of CEvent::operator += (CEvent event). 

369 """ 

370 self.rainfall += other.rainfall 

371 self.daylight_hours += other.daylight_hours 

372 self.max_temp = max(self.max_temp, other.max_temp) 

373 self.min_temp = min(self.min_temp, other.min_temp) 

374 self.forage_day = self.forage_day and other.forage_day 

375 self.forage_inc += other.forage_inc 

376 return self 

377 

378 

379class WeatherEvents: 

380 """Collection manager for weather data driving colony simulation dynamics. 

381 

382 Manages chronological weather events and provides environmental data access 

383 for colony simulation. Handles weather data interpolation, daylight calculations, 

384 foraging condition assessment, and seasonal pattern analysis. 

385 

386 Integrates with colony simulation by providing daily environmental drivers 

387 that influence bee behavior, brood development, resource collection, and 

388 overwintering survival. Temperature thresholds determine foraging activity 

389 while daylight patterns drive circadian and seasonal behaviors. 

390 

391 Attributes: 

392 filename (str): Source filename for weather data (informational) 

393 events (list): Chronological collection of Event objects 

394 has_been_initialized (bool): Whether weather data has been loaded 

395 latitude (float): Geographic latitude for daylight calculations (degrees) 

396 current_index (int): Current position for iteration through events 

397 """ 

398 

399 def __init__(self, latitude=30.0): 

400 self.filename = "" 

401 self.events = [] 

402 self.has_been_initialized = False 

403 self.latitude = latitude 

404 self.current_index = 0 # For iteration support 

405 

406 def clear_all_events(self): 

407 self.events.clear() 

408 self.has_been_initialized = False 

409 self.current_index = 0 

410 

411 def add_event(self, event): 

412 self.events.append(event) 

413 self.has_been_initialized = True 

414 

415 def remove_current_event(self): 

416 """ 

417 Port of CWeatherEvents::RemoveCurrentEvent(). 

418 Removes the event at current index. 

419 """ 

420 if 0 <= self.current_index < len(self.events): 

421 del self.events[self.current_index] 

422 if self.current_index >= len(self.events) and self.events: 

423 self.current_index = len(self.events) - 1 

424 return True 

425 return False 

426 

427 def get_total_events(self): 

428 return len(self.events) 

429 

430 def set_file_name(self, fname): 

431 self.filename = fname 

432 

433 def get_file_name(self): 

434 return self.filename 

435 

436 def check_sanity(self): 

437 """ 

438 Port of CWeatherEvents::CheckSanity(). 

439 Basic validation method (implementation can be expanded as needed). 

440 """ 

441 return 0 # 0 indicates no errors 

442 

443 def set_latitude(self, lat): 

444 if lat != self.latitude: 

445 self.latitude = lat 

446 # Update all daylight hours and forage attributes for events based on updated latitude 

447 if self.events: 

448 for event in self.events: 

449 event.daylight_hours = event.calc_today_daylight_from_latitude( 

450 self.latitude 

451 ) 

452 event.update_forage_attribute_for_event( 

453 self.latitude, event.windspeed 

454 ) 

455 

456 def get_latitude(self): 

457 return self.latitude 

458 

459 def is_initialized(self): 

460 return self.has_been_initialized 

461 

462 def set_initialized(self, val): 

463 self.has_been_initialized = val 

464 

465 def go_to_first_event(self): 

466 """ 

467 Port of CWeatherEvents::GoToFirstEvent(). 

468 Sets current position to first event. 

469 """ 

470 self.current_index = 0 

471 

472 def get_first_event(self): 

473 """ 

474 Port of CWeatherEvents::GetFirstEvent(). 

475 Returns first event and sets position to iterate from there. 

476 """ 

477 if self.events: 

478 self.current_index = 0 

479 return self.events[0] 

480 return None 

481 

482 def get_next_event(self, current_index=None): 

483 """ 

484 Port of CWeatherEvents::GetNextEvent(). 

485 If current_index is provided, returns the next event after that index. 

486 If not provided, uses internal current_index and advances it. 

487 """ 

488 if current_index is not None: 

489 if 0 <= current_index + 1 < len(self.events): 

490 return self.events[current_index + 1] 

491 return None 

492 else: 

493 # Use internal index and advance 

494 self.current_index += 1 

495 if 0 <= self.current_index < len(self.events): 

496 return self.events[self.current_index] 

497 return None 

498 

499 def get_current_time(self, current_index=None): 

500 """ 

501 Port of CWeatherEvents::GetCurrentTime(). 

502 Returns the time of the current event. 

503 """ 

504 index = current_index if current_index is not None else self.current_index 

505 if 0 <= index < len(self.events): 

506 return self.events[index].time 

507 return None 

508 

509 def get_day_event(self, the_time): 

510 """ 

511 Port of CWeatherEvents::GetDayEvent(COleDateTime theTime). 

512 Finds and returns event matching the given date. 

513 IMPORTANT: This method sets the current_index to the found event's position, 

514 so subsequent calls to get_next_event() will advance from this position. 

515 """ 

516 old_position = self.current_index 

517 self.go_to_first_event() 

518 for i, event in enumerate(self.events): 

519 if event.time.date() == the_time.date(): 

520 self.current_index = i 

521 return event 

522 # If no match found, restore old position 

523 self.current_index = old_position 

524 return None 

525 

526 def get_beginning_time(self): 

527 return self.events[0].time if self.events else None 

528 

529 def get_ending_time(self): 

530 return self.events[-1].time if self.events else None