import math from datetime import datetime, timedelta from fansifter_common.utils import timezone COOLDOWN_DELTA = timedelta(minutes=5) COOLDOWN_START_QUOTA = 10000.0 DECAY_RATE = 0.4 FULL_COOLDOWN_DAYS = 30.0 GROWTH_RATE = 0.336472 MAX_SENDING_DELAY = timedelta(minutes=0) MAX_WARMUP_STEP = timedelta(days=1) MIN_QUOTA = 20.0 MAX_QUOTA = 5_400_000.0 QUOTA_DELTA = timedelta(minutes=1) SECONDS_PER_DAY = 86400.0 def calculate_current_hourly_quota( *, last_sent_at: datetime | None, last_hourly_quota: float | None, current_time: datetime | None, growth_rate: float | None = None, use_cooldown: bool = True, ) -> float: """Get the current hourly quota.""" current_time = current_time or timezone.now() last_hourly_quota = last_hourly_quota or MIN_QUOTA # Compute cooldown period if use_cooldown: cooldown_period = (current_time - last_sent_at) if last_sent_at else None else: cooldown_period = None # Handle cooldown logic if cooldown_period and last_sent_at: has_full_cooldown = cooldown_period >= timedelta(days=FULL_COOLDOWN_DAYS) has_partial_cooldown = ( cooldown_period > COOLDOWN_DELTA and last_hourly_quota >= COOLDOWN_START_QUOTA ) if has_full_cooldown or has_partial_cooldown: cooldown_days = cooldown_period.total_seconds() / SECONDS_PER_DAY return calculate_quota_after_cooldown( last_hourly_quota=last_hourly_quota, cooldown_days=cooldown_days, ) # Handle warmup logic warmup_period = cooldown_period or QUOTA_DELTA warmup_period = min(warmup_period, MAX_WARMUP_STEP) warmup_days = warmup_period.total_seconds() / SECONDS_PER_DAY current_hourly_quota = calculate_quota_with_warmup( last_hourly_quota=last_hourly_quota, warmup_days=warmup_days, growth_rate=growth_rate, ) return min(MAX_QUOTA, current_hourly_quota) def calculate_available_quota( *, last_sent_at: datetime | None, last_hour_sent_emails: int, current_hourly_quota: float, current_time: datetime | None, ) -> int: """Calculate how many emails can be sent at this point in the hour.""" current_time = current_time or timezone.now() minutes_passed_in_hour = current_time.minute + 1 percentage_of_hour_passed = minutes_passed_in_hour / 60 available_quota_based_on_time = math.ceil( current_hourly_quota * percentage_of_hour_passed ) sending_delay = ( min(current_time - last_sent_at, MAX_SENDING_DELAY) if last_sent_at else MAX_SENDING_DELAY ) allowed_ticks = 1 + sending_delay.total_seconds() / 60 max_emails_allowed_to_send = math.floor( max((minutes_passed_in_hour - allowed_ticks), 0) / 60 * current_hourly_quota ) available_quota = available_quota_based_on_time - max( last_hour_sent_emails, max_emails_allowed_to_send ) max_for_allowed_ticks = max(1, round(current_hourly_quota * allowed_ticks / 60)) return max(min(available_quota, max_for_allowed_ticks), 0) def calculate_quota_with_warmup( *, last_hourly_quota: float, warmup_days: float, growth_rate: float | None = None ) -> float: """Increase the quota exponentially with active sending (warmup).""" if last_hourly_quota <= 0: return MIN_QUOTA growth_rate = growth_rate or GROWTH_RATE t_current = (1 / growth_rate) * math.log(last_hourly_quota / MIN_QUOTA) t_next = t_current + warmup_days return MIN_QUOTA * math.pow(math.e, (growth_rate * t_next)) def calculate_quota_after_cooldown( *, last_hourly_quota: float, cooldown_days: float ) -> float: """Decrease the quota exponentially after inactivity (cooldown).""" if cooldown_days <= 0: return last_hourly_quota if cooldown_days >= FULL_COOLDOWN_DAYS: return MIN_QUOTA new_quota = last_hourly_quota / ( 1 + math.pow(math.e, (DECAY_RATE * (cooldown_days - FULL_COOLDOWN_DAYS / 2))) ) return max(new_quota, MIN_QUOTA)