This application follows functional composition principles instead of object-oriented programming, adhering to DRY (Don't Repeat Yourself) and KISS (Keep It Simple, Stupid) principles.
- Functions as the primary unit of organization
- Pure functions where possible
- Explicit dependencies passed as parameters
- No hidden state or side effects
- Reusable functions across modules
- Configuration centralized in one place
- Common patterns extracted (retry logic, error handling)
- Straightforward data flow
- Minimal abstractions
- Clear function signatures
- Direct error handling
weatherstationdatabridge/
├── models.py # Domain models and exceptions
├── config.py # Configuration loading
├── wu_client.py # Weather Underground API (raw data fetch)
├── transformer.py # ALL data transformations (wind, precipitation, UV)
├── windy_client.py # Windy API
├── retry.py # Retry logic
├── orchestrator.py # Sync orchestration
├── scheduler.py # Task scheduling
├── health.py # Health monitoring
└── __init__.py # CLI entry point
1. Configuration Loading (config.py)
└─> Environment Variables → Configuration object
2. Scheduler Loop (scheduler.py)
└─> Every N minutes → Execute Sync Cycle
3. Sync Cycle (orchestrator.py)
├─> For each station (concurrent, max 2):
│ ├─> Fetch Raw WU Data (wu_client.py)
│ ├─> Check Last Sent Timestamp (deduplication)
│ ├─> Transform Data: Convert Wind + Track Precipitation + Convert UV (transformer.py)
│ └─> Send to Windy with Retry (windy_client.py + retry.py)
└─> Return Sync Results
4. Health Monitoring (health.py)
└─> HTTP endpoint → Check last sync status
Instead of classes with methods, we compose functions:
# Configuration
config = load_configuration()
# Create specialized functions
sync_executor = create_sync_executor(config)
# Compose into scheduler
run_scheduler(sync_executor, config.sync_interval_minutes)-
API Client Level: Raise specific exceptions
APIConnectionErrorAuthenticationErrorStationNotFoundRateLimitExceeded
-
Retry Level: Handle transient errors
- Exponential backoff
- Max attempts limit
- Raises
MaxRetriesExceeded
-
Orchestrator Level: Catch all errors
- Continue on single station failure
- Log errors
- Return
SyncResultwith error details
-
Scheduler Level: Keep running
- Log exceptions
- Wait before retry
- Never crash service
To prevent duplicate API submissions and unnecessary warnings, the orchestrator maintains an in-memory cache of observation timestamps per station.
_last_sent_timestamps: dict[str, datetime] = {}
# Check if timestamp already seen
if _last_sent_timestamps.get(station_id) == observation.timestamp:
logger.info(f"Skipping station {station_id}: observation already sent")
return # Skip sending
# Mark timestamp as seen BEFORE attempting send
# This prevents retrying the same timestamp on failure
_last_sent_timestamps[station_id] = observation.timestamp
# ... transform and send ...Timestamps are cached BEFORE the send attempt, not after. This ensures:
- First encounter: Cache timestamp → attempt send
- If send succeeds: No retry needed (already cached)
- If send fails: Timestamp already cached → won't retry same observation
- Next cycle: Same timestamp detected → skipped with INFO log
- New data: New timestamp → attempt send
This prevents wasting retry attempts on observations that will never succeed (e.g., "timestamp already present" errors from Windy).
- Prevents duplicate warnings: Eliminates "timestamp already present" errors from Windy API
- Reduces wasted retries: Doesn't retry observations that already failed
- Handles stale data gracefully: Weather stations with slow reporting (15+ min intervals) are handled efficiently
- Simple & stateless: No persistence needed; acceptable to resend after app restart
graph TD
A["Fetch Observation"] --> B{"Timestamp\nAlready Cached?"}
B -->|Yes| C["Skip Send\n(INFO log)"]
B -->|No| D["Cache Timestamp"]
D --> E["Transform Data"]
E --> F["Send to Windy"]
F -->|Success| G["Complete"]
F -->|Failure| H["Error Logged\n(Timestamp Cached)"]
H --> I["Next Cycle:\nTimestamp Cached\n→ Skip"]
Weather Underground provides daily cumulative precipitation totals, but Windy expects hourly precipitation. The transformer maintains an in-memory cache to calculate hourly deltas.
_precipitation_cache: dict[str, tuple[datetime, Optional[float], Optional[float]]] = {}
def calculate_hourly_precipitation(station_id, current_timestamp, current_precip_mm, current_precip_in):
# First reading: store and return None
if station_id not in _precipitation_cache:
_precipitation_cache[station_id] = (current_timestamp, current_precip_mm, current_precip_in)
return None, None
# Get previous values
prev_timestamp, prev_mm, prev_in = _precipitation_cache[station_id]
# Detect midnight reset (daily total decreases)
if current_precip_mm < prev_mm:
_precipitation_cache[station_id] = (current_timestamp, current_precip_mm, current_precip_in)
return None, None # Conservative: skip after reset
# Calculate hourly delta
hourly_mm = current_precip_mm - prev_mm
hourly_in = current_precip_in - prev_in
# Update cache and return
_precipitation_cache[station_id] = (current_timestamp, current_precip_mm, current_precip_in)
return hourly_mm, hourly_in- Accurate hourly data: Converts daily totals to hourly precipitation
- Midnight reset detection: Handles daily counter reset gracefully
- Per-station tracking: Independent history for each weather station
- Simple & stateless: In-memory cache, acceptable to reset on restart
- Conservative approach: Returns None for first reading and after midnight reset
graph TD
A["Fetch Current Total"] --> B{"Station in\nCache?"}
B -->|No| C["Store in Cache\nReturn None"]
B -->|Yes| D{"Current <\nPrevious?"}
D -->|Yes| E["Midnight Reset\nUpdate Cache\nReturn None"]
D -->|No| F["Calculate Delta\nUpdate Cache\nReturn Hourly"]
All data quality transformations happen in transformer.py for consistency and maintainability.
Weather Underground's metric API returns wind speeds in km/h, but Windy expects m/s.
# In transformer.py
wind_speed_mps = None
if observation.wind_speed_kmh is not None:
wind_speed_mps = observation.wind_speed_kmh / 3.6
wind_gust_mps = None
if observation.wind_gust_kmh is not None:
wind_gust_mps = observation.wind_gust_kmh / 3.6Conversion factor: 1 m/s = 3.6 km/h
Location: transformer.py (alongside other transformations)
Windy expects UV index as an integer, but Weather Underground provides a float.
# In transformer.py
uv_value = None
if observation.uv_index is not None:
uv_value = int(observation.uv_index) # Truncate to integerLocation: transformer.py (alongside other transformations)
async def sync_station(...):
# Fetch (I/O bound)
observation = fetch_weather_underground_data(...)
# Transform (CPU bound, but fast)
windy_obs = transform_to_windy_format(...)
# Send with retry (I/O bound)
await retry_with_backoff(...)semaphore = asyncio.Semaphore(2) # Max 2 concurrent
async def sync_with_semaphore(...):
async with semaphore:
return await sync_station(...)All behavior controlled by environment variables:
config = Configuration(
windy_api_key="...",
wu_api_key="...",
wu_station_ids=["KSTATION1", "KSTATION2"],
windy_station_ids=["0", "1"],
sync_interval_minutes=5,
retry_attempts=3,
retry_delay_seconds=5,
)- Test individual functions
- Mock external dependencies
- Test error conditions
- Verify data transformations (wind speed, precipitation, UV index)
Test Coverage (30 tests):
- Configuration tests (7 tests)
- Model validation (4 tests)
- Transformer tests (3 tests)
- Bug fix verification (5 tests)
- Precipitation tracking (11 tests)
- Use
test-synccommand - Test with real (or mocked) APIs
- Verify end-to-end flow
- Wind speed conversion: Verify km/h → m/s (÷ 3.6)
- Precipitation tracking: Test first reading, normal increase, midnight reset, edge cases
- UV index: Verify float → integer conversion
- Minimal state storage
- Streaming data processing
- Station metadata cached
- Timestamp deduplication (~50 bytes per station)
- Precipitation tracking cache (~100 bytes per station)
- Target: <128MB
- Mostly I/O bound (network)
- Async prevents blocking
- Idle between syncs
- Target: <0.5 cores
- Connection pooling (httpx)
- Concurrent requests (asyncio)
- Automatic retries
- Target: <1MB/min total
- Python 3.11 slim base
- UV for fast dependency install
- Health check on port 8080
- Graceful shutdown handling
- All configuration via ENV
- No files to mount (except logs)
- Secrets in ENV (not in code)
- Health check endpoint
- Structured logging
- Sync result tracking
- Create new client module (e.g.,
accuweather_client.py) - Implement fetch function
- Add to orchestrator
- Create new client module (e.g.,
weatherapi_client.py) - Implement send function
- Add to orchestrator
- Add transformer function
- Compose in orchestrator
- Test with unit tests
- Unnecessary abstraction: No complex state to encapsulate
- Hidden dependencies: Methods access instance variables
- Testing complexity: Mocking objects harder than functions
- Cognitive overhead: Understanding class hierarchies
- Explicit dependencies: All inputs are parameters
- Easy testing: Mock functions, not objects
- Simple composition: Functions compose naturally
- Clear data flow: Input → Transform → Output
class WeatherSyncService:
def __init__(self, config):
self.config = config
self.wu_client = WUClient(config.wu_api_key)
self.windy_client = WindyClient(config.windy_api_key)
def sync_station(self, station_id):
# Hidden dependencies on self.config, self.wu_client, etc.
passdef sync_station(station_id, wu_api_key, windy_api_key, ...):
# All dependencies explicit as parameters
observation = fetch_weather_underground_data(wu_api_key, station_id)
windy_obs = transform_to_windy_format(observation, ...)
send_to_windy(windy_api_key, windy_obs)This architecture achieves:
- ✅ Simplicity: Easy to understand and modify
- ✅ Testability: Pure functions are easy to test (30 tests covering all data transformations)
- ✅ Reliability: Explicit error handling at every level
- ✅ Performance: Async I/O with controlled concurrency
- ✅ Maintainability: Small, focused modules
- ✅ Deployability: Containerized with health checks
- ✅ Data Quality: Accurate unit conversions and precipitation tracking with comprehensive validation