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
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-30 13:34 +0000
1"""BeePop+ Weather Events Module.
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.
7Classes:
8 Event: Individual weather record with temperature, precipitation, and daylight data
9 WeatherEvents: Collection manager for weather data with interpolation and analysis
10"""
12from datetime import datetime
13import math
14from pybeepop.beepop.globaloptions import GlobalOptions
15from pybeepop.beepop.coldstoragesimulator import ColdStorageSimulator
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)
26class Event:
27 """Individual weather event representing daily environmental conditions.
29 Represents a single day's weather data including temperature, precipitation,
30 wind conditions.
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 """
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
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
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 )
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()
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)
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)
116 def set_forage(self, forage):
117 self.forage_day = forage
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
130 def get_daylight_hours(self):
131 return self.daylight_hours
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
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)
161 def set_forage_inc(self, value):
162 self.forage_inc = value
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)
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.
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
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
224 # Missing getters from C++ version
225 def get_time(self):
226 return self.time
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
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
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
261 def get_rainfall(self):
262 return self.rainfall
264 def get_windspeed(self):
265 return self.windspeed
267 # Missing setters from C++ version
268 def set_time(self, time):
269 self.time = time
271 def set_temp(self, temp):
272 self.temp = temp
274 def set_max_temp(self, max_temp):
275 self.max_temp = max_temp
277 def set_min_temp(self, min_temp):
278 self.min_temp = min_temp
280 def set_rainfall(self, rainfall):
281 self.rainfall = rainfall
283 def set_windspeed(self, windspeed):
284 self.windspeed = windspeed
286 def set_daylight_hours(self, hrs):
287 self.daylight_hours = hrs
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)
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
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.
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)
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
329 if daytime_range == 0:
330 prop_threshold = 0
331 else:
332 prop_threshold = (t_thresh - t_ave) / daytime_range
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
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
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
379class WeatherEvents:
380 """Collection manager for weather data driving colony simulation dynamics.
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.
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.
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 """
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
406 def clear_all_events(self):
407 self.events.clear()
408 self.has_been_initialized = False
409 self.current_index = 0
411 def add_event(self, event):
412 self.events.append(event)
413 self.has_been_initialized = True
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
427 def get_total_events(self):
428 return len(self.events)
430 def set_file_name(self, fname):
431 self.filename = fname
433 def get_file_name(self):
434 return self.filename
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
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 )
456 def get_latitude(self):
457 return self.latitude
459 def is_initialized(self):
460 return self.has_been_initialized
462 def set_initialized(self, val):
463 self.has_been_initialized = val
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
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
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
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
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
526 def get_beginning_time(self):
527 return self.events[0].time if self.events else None
529 def get_ending_time(self):
530 return self.events[-1].time if self.events else None