Skip to main content
Glama

Grants Search MCP Server

timing_metrics.py13.8 kB
"""Timing Score calculations for preparation adequacy assessment.""" import logging from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple import numpy as np from mcp_server.models.grants_schemas import OpportunityV1 from mcp_server.models.analytics_schemas import ScoreBreakdown, IndustryConstants logger = logging.getLogger(__name__) class TimingCalculator: """ Calculate Timing scores for grant opportunities. Evaluates whether there is adequate time to prepare a competitive application and considers deadline competition factors. """ def __init__(self): self.constants = IndustryConstants() def parse_deadline(self, close_date: Optional[str]) -> Optional[datetime]: """ Parse grant deadline from various date formats. Args: close_date: Close date string from grant data Returns: Parsed datetime object or None """ if not close_date: return None # Common date formats in grant data date_formats = [ "%Y-%m-%d", # 2024-03-15 "%m/%d/%Y", # 03/15/2024 "%d/%m/%Y", # 15/03/2024 "%Y-%m-%dT%H:%M:%S", # 2024-03-15T23:59:59 "%Y-%m-%d %H:%M:%S", # 2024-03-15 23:59:59 "%B %d, %Y", # March 15, 2024 "%b %d, %Y", # Mar 15, 2024 ] for fmt in date_formats: try: return datetime.strptime(close_date.strip(), fmt) except ValueError: continue # Try parsing just the date part if there's time information try: date_part = close_date.split('T')[0].split(' ')[0] return datetime.strptime(date_part, "%Y-%m-%d") except (ValueError, IndexError): pass logger.warning(f"Could not parse deadline: {close_date}") return None def calculate_days_until_deadline(self, close_date: Optional[str]) -> Optional[int]: """ Calculate days remaining until deadline. Args: close_date: Grant close date string Returns: Number of days until deadline, or None if unparseable """ deadline = self.parse_deadline(close_date) if not deadline: return None today = datetime.utcnow() days_remaining = (deadline - today).days return max(0, days_remaining) # Don't return negative days def get_optimal_preparation_days( self, award_ceiling: Optional[float], agency_code: str, complexity_factors: Optional[Dict] = None ) -> int: """ Determine optimal preparation time based on grant characteristics. Args: award_ceiling: Maximum award amount agency_code: Agency code complexity_factors: Additional complexity factors Returns: Optimal preparation days """ award_amount = award_ceiling or 100000 # Base optimal days by award size if award_amount < 100000: base_days = self.constants.OPTIMAL_PREP_DAYS_SMALL elif award_amount < 1000000: base_days = self.constants.OPTIMAL_PREP_DAYS_MEDIUM else: base_days = self.constants.OPTIMAL_PREP_DAYS_LARGE # Agency-specific adjustments agency_adjustments = { 'NIH': 1.3, # Complex requirements need more time 'NSF': 1.1, # Moderate additional time 'DOE': 1.2, # Technical complexity 'DOD': 1.4, # High compliance requirements 'NASA': 1.2, # Technical complexity 'EPA': 1.1, # Moderate requirements 'USDA': 1.0, # Standard time } main_agency = agency_code.split('-')[0] if agency_code else 'OTHER' adjustment = agency_adjustments.get(main_agency, 1.1) # Complexity factor adjustments if complexity_factors: if complexity_factors.get('requires_partnerships', False): adjustment *= 1.2 if complexity_factors.get('requires_preliminary_data', False): adjustment *= 1.1 if complexity_factors.get('first_submission', True): adjustment *= 1.1 return int(base_days * adjustment) def calculate_preparation_adequacy_score( self, days_available: Optional[int], optimal_days: int ) -> float: """ Calculate preparation adequacy score. Formula: Prep_Score = min(100, (Days_Available / Optimal_Prep_Days) * 100) Args: days_available: Days available until deadline optimal_days: Optimal preparation days Returns: Preparation adequacy score (0-100) """ if days_available is None: return 50.0 # Neutral score if deadline unknown if optimal_days <= 0: return 100.0 adequacy_ratio = days_available / optimal_days # Score with non-linear scaling (diminishing returns after optimal) if adequacy_ratio >= 1.0: # Bonus for extra time, but diminishing returns score = 100 - (10 * (1 / (1 + adequacy_ratio - 1))) else: # Penalty for insufficient time score = adequacy_ratio * 100 return min(100, max(0, score)) def assess_deadline_competition( self, close_date: Optional[str], concurrent_deadlines: Optional[List[str]] = None, max_concurrent_capacity: int = 3 ) -> float: """ Assess competition from concurrent deadlines. Args: close_date: This grant's deadline concurrent_deadlines: List of other deadlines in same period max_concurrent_capacity: Maximum grants user can handle simultaneously Returns: Deadline competition factor (0.0-1.0) """ if not close_date: return 1.0 # Neutral if no deadline deadline = self.parse_deadline(close_date) if not deadline: return 1.0 # Count concurrent deadlines within +/- 2 weeks concurrent_count = 0 if concurrent_deadlines: for other_deadline in concurrent_deadlines: other_date = self.parse_deadline(other_deadline) if other_date: days_diff = abs((deadline - other_date).days) if days_diff <= 14: # Within 2 weeks concurrent_count += 1 # Calculate competition factor if concurrent_count == 0: return 1.0 # No competition elif concurrent_count < max_concurrent_capacity: return 1.0 - (concurrent_count * 0.1) # 10% penalty per concurrent deadline else: return max(0.3, 1.0 - (concurrent_count * 0.2)) # Higher penalty, minimum 30% def assess_resubmission_possibility( self, agency_code: str, close_date: Optional[str] ) -> float: """ Assess whether resubmission is possible if first attempt fails. Args: agency_code: Agency code close_date: Grant deadline Returns: Resubmission possibility factor (0.8-1.2) """ # Agencies with regular resubmission cycles resubmission_friendly = { 'NIH': 1.2, # Regular cycles, encourages resubmission 'NSF': 1.1, # Annual cycles usually 'DOE': 1.0, # Varies by program 'DOD': 0.9, # Often one-time opportunities 'NASA': 0.9, # Limited opportunities 'EPA': 1.0, # Varies by program 'USDA': 1.1, # Regular cycles } main_agency = agency_code.split('-')[0] if agency_code else 'OTHER' base_factor = resubmission_friendly.get(main_agency, 1.0) # Check if this appears to be a regular cycle vs one-time opportunity if close_date: deadline = self.parse_deadline(close_date) if deadline: # Heuristic: regular cycles often have deadlines at consistent times month = deadline.month if month in [3, 6, 9, 12]: # Quarterly base_factor *= 1.05 elif month in [1, 7]: # Semi-annual base_factor *= 1.1 return base_factor def calculate_timing_score( self, opportunity: OpportunityV1, user_profile: Optional[Dict] = None, concurrent_opportunities: Optional[List[OpportunityV1]] = None ) -> ScoreBreakdown: """ Calculate comprehensive Timing score. Args: opportunity: Grant opportunity to score user_profile: User profile with preferences concurrent_opportunities: Other opportunities being considered Returns: ScoreBreakdown with transparent calculation """ try: # Extract key information close_date = opportunity.summary.close_date award_ceiling = opportunity.summary.award_ceiling agency = opportunity.agency_code # Calculate days until deadline days_available = self.calculate_days_until_deadline(close_date) # Get optimal preparation time complexity_factors = { 'first_submission': user_profile.get('first_time_applicant', True) if user_profile else True, 'requires_partnerships': False, # Would need to analyze description 'requires_preliminary_data': False, # Would need to analyze requirements } optimal_days = self.get_optimal_preparation_days( award_ceiling, agency, complexity_factors ) # Calculate preparation adequacy prep_score = self.calculate_preparation_adequacy_score(days_available, optimal_days) # Assess deadline competition concurrent_deadlines = [] if concurrent_opportunities: concurrent_deadlines = [opp.summary.close_date for opp in concurrent_opportunities if opp.summary.close_date] max_capacity = user_profile.get('max_concurrent_applications', 3) if user_profile else 3 competition_factor = self.assess_deadline_competition(close_date, concurrent_deadlines, max_capacity) # Assess resubmission possibility resubmission_factor = self.assess_resubmission_possibility(agency, close_date) # Calculate final timing score final_score = prep_score * competition_factor * resubmission_factor final_score = min(100, max(0, final_score)) # Build calculation explanation if days_available is not None: calculation = f"Timing: {days_available} days available / {optimal_days} optimal = {prep_score:.1f}%" else: calculation = f"Timing: Unknown deadline, using neutral score" # Interpret the score if final_score >= 80: interpretation = "Excellent timing (ample preparation time)" elif final_score >= 60: interpretation = "Good timing (adequate preparation time)" elif final_score >= 40: interpretation = "Tight timing (rushed preparation)" elif final_score >= 20: interpretation = "Poor timing (insufficient preparation time)" else: interpretation = "Critical timing (deadline too close)" # Calculate percentile percentile = final_score # Use score directly as percentile approximation # Industry benchmark benchmark = f"Optimal preparation: {optimal_days} days for this grant type" return ScoreBreakdown( value=final_score, calculation=calculation, components={ "days_available": days_available, "optimal_days": optimal_days, "preparation_adequacy": prep_score, "deadline_competition_factor": competition_factor, "resubmission_factor": resubmission_factor, "concurrent_deadlines": len(concurrent_deadlines), "close_date": close_date, "formula": "Prep_Score × Competition_Factor × Resubmission_Factor" }, interpretation=interpretation, percentile=percentile, industry_benchmark=benchmark ) except Exception as e: logger.error(f"Error calculating timing score: {e}") return ScoreBreakdown( value=50.0, calculation="Error in calculation", components={"error": str(e)}, interpretation="Unable to calculate timing score", percentile=None, industry_benchmark=None )

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Tar-ive/grants-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server