diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 35cbbdb..fa2a0c2 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -4,7 +4,7 @@ name: Docker on: workflow_dispatch: push: - branches: [master] + branches: [hybrid] pull_request: release: types: [published] @@ -58,7 +58,7 @@ jobs: env: REGISTRY_NAME: 'ghcr.io' REPOSITORY: ${{ needs.pre-job.outputs.owner }}/weather-server - TAG_OLD: master${{ matrix.suffix }} + TAG_OLD: hybrid${{ matrix.suffix }} TAG_PR: ${{ github.event.number == 0 && github.ref_name || format('pr-{0}', github.event.number) }}${{ matrix.suffix }} TAG_COMMIT: commit-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}${{ matrix.suffix }} run: | @@ -255,4 +255,4 @@ jobs: run: exit 1 - name: All jobs passed or skipped if: ${{ !(contains(needs.*.result, 'failure')) }} - run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" \ No newline at end of file + run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}" diff --git a/Dockerfile b/Dockerfile index 61eed36..9ee5b38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,17 @@ EXPOSE 8080 WORKDIR /weather COPY /package.json ./ RUN mkdir baselineEToData + +# Create data directory for persistent storage (PR #144) +RUN mkdir -p /data + COPY --from=build_eto /eto/Baseline_ETo_Data.bin ./baselineEToData COPY --from=build_node /weather/dist ./dist -CMD ["npm", "run", "start"] +# Set persistence location for observations.json and geocoderCache.json (PR #144) +ENV PERSISTENCE_LOCATION=/data + +# Declare volume for persistent data +VOLUME /data + +CMD ["npm", "run", "start"] \ No newline at end of file diff --git a/docs/hybrid.md b/docs/hybrid.md new file mode 100644 index 0000000..9b9eea9 --- /dev/null +++ b/docs/hybrid.md @@ -0,0 +1,451 @@ +# 📚 Hybrid Weather Provider - Data Flow Documentation + +## 🎯 Overview: Three Types of Weather Data + +The Hybrid Provider works with **three different data types** that are often confused: + +### 1️⃣ **Current Weather** (Right Now) +**Method:** `getWeatherDataInternal()` +**Source:** Local Weather Station +**Time Period:** Last 24 hours (for averages) +**Used For:** +- Mobile app display ("Current Weather") +- Rain delay decisions +- "Is it raining RIGHT NOW?" +- "What's the temperature RIGHT NOW?" + +**Example Data:** +```typescript +{ + temp: 18.5, // Current temperature + humidity: 75, // Current humidity + raining: true, // Is it raining NOW? + precip: 2.5, // mm rain in last 24h + wind: 12, // Current wind + weatherProvider: "local" +} +``` + +**Important:** This data is **REAL-TIME MEASUREMENTS** from your station! + +--- + +### 2️⃣ **Historical Data** (Past) +**Method:** `getWateringDataInternal()` from LocalProvider +**Source:** Local Weather Station +**Time Period:** Last 7 days + today up to now +**Used For:** +- Zimmerman Watering Scale calculation +- Multi-Day algorithm +- Trend analysis + +**Example Data:** +```typescript +[ + { + periodStartTime: 1736899200, // Jan 15, 00:00 + temp: 16.2, // Daily average + humidity: 68, + precip: 0, // No rain that day + minTemp: 12.5, + maxTemp: 19.8, + solarRadiation: 3.2, // kWh/m²/day + windSpeed: 8.5, + weatherProvider: "local" + }, + { + periodStartTime: 1736985600, // Jan 16, 00:00 + temp: 17.5, + humidity: 72, + precip: 5.2, // 5.2mm rain! + minTemp: 14.1, + maxTemp: 21.3, + solarRadiation: 2.1, // Less sun (cloudy) + windSpeed: 10.2, + weatherProvider: "local" + }, + // ... more days ... + { + periodStartTime: 1737504000, // Jan 18, 00:00 (TODAY) + temp: 18.2, // Average from 00:00-now (6PM) + humidity: 75, + precip: 2.5, // Rain today so far + minTemp: 16.5, + maxTemp: 19.9, + solarRadiation: 2.8, // So far today + windSpeed: 9.1, + weatherProvider: "local" + } +] +``` + +**Important:** Each day is **MEASURED** by your station - no forecasts! + +--- + +### 3️⃣ **Forecast Data** (Future) +**Method:** `getWateringDataInternal()` from ForecastProvider +**Source:** Apple Weather / OpenMeteo / etc. +**Time Period:** Tomorrow through +7 days +**Used For:** +- Zimmerman Watering Scale calculation +- Forward-looking irrigation planning +- "What will the weather be like the next few days?" + +**Example Data:** +```typescript +[ + { + periodStartTime: 1737590400, // Jan 19, 00:00 (TOMORROW) + temp: 20.5, // Predicted average temperature + humidity: 65, + precip: 0, // No rain expected + minTemp: 17.2, + maxTemp: 23.8, + solarRadiation: 4.5, // Sunny expected + windSpeed: 7.2, + weatherProvider: "OpenMeteo" + }, + { + periodStartTime: 1737676800, // Jan 20, 00:00 + temp: 22.1, + humidity: 60, + precip: 0, + minTemp: 18.5, + maxTemp: 25.7, + solarRadiation: 5.1, + windSpeed: 6.8, + weatherProvider: "OpenMeteo" + }, + // ... more days through +7 ... +] +``` + +**Important:** This data is **PREDICTIONS** - not measured! + +--- + +## 🔄 How Hybrid Combines These + +### Scenario: January 18, 6:00 PM + +```typescript +// 1. Open OpenSprinkler App → Shows "Current Weather" +const current = await hybrid.getWeatherDataInternal(); +// → Shows: 18.2°C, 75% humidity, raining (2.5mm today) +// → Source: Local Station (REAL-TIME) + +// 2. Plan irrigation → Calculate Zimmerman +const watering = await hybrid.getWateringDataWithForecastProvider(coords, pws, "OpenMeteo"); +// → Returns: +[ + // MEASURED (Past): + { day: "Jan 11", temp: 15.2, precip: 0, source: "local" }, + { day: "Jan 12", temp: 16.8, precip: 1.2, source: "local" }, + { day: "Jan 13", temp: 17.1, precip: 0, source: "local" }, + { day: "Jan 14", temp: 15.9, precip: 0, source: "local" }, + { day: "Jan 15", temp: 16.2, precip: 0, source: "local" }, + { day: "Jan 16", temp: 17.5, precip: 5.2, source: "local" }, // Rain! + { day: "Jan 17", temp: 18.0, precip: 0, source: "local" }, + { day: "Jan 18", temp: 18.2, precip: 2.5, source: "local" }, // Today through 6PM + + // FORECAST (Future): + { day: "Jan 19", temp: 20.5, precip: 0, source: "OpenMeteo" }, // Tomorrow + { day: "Jan 20", temp: 22.1, precip: 0, source: "OpenMeteo" }, + { day: "Jan 21", temp: 21.8, precip: 0, source: "OpenMeteo" }, + { day: "Jan 22", temp: 20.2, precip: 3.0, source: "OpenMeteo" }, // Rain expected + { day: "Jan 23", temp: 18.5, precip: 1.5, source: "OpenMeteo" }, + { day: "Jan 24", temp: 19.1, precip: 0, source: "OpenMeteo" }, + { day: "Jan 25", temp: 20.8, precip: 0, source: "OpenMeteo" } +] + +// 3. Zimmerman algorithm analyzes these 15 days: +// - Jan 16: 5.2mm rain (measured!) → Soil was wet +// - Jan 18: 2.5mm rain (measured!) → Soil is wet now +// - Jan 22: 3.0mm rain expected → Soil will be wet +// → Decision: Reduce watering to 40% +``` + +--- + +## 🎯 Why This Is Brilliant + +### ✅ Advantages of the Hybrid Approach: + +**1. Precise Past** +- You know EXACTLY how much it rained +- You know EXACTLY how warm it was +- No estimates, no errors + +**2. Reliable Future** +- Professional weather models +- Multiple data sources combined +- Better than "just continue the trend" + +**3. Optimal Decisions** +``` +Bad Approach (forecast only): +"It rained 4mm on Jan 16 (forecast said 5mm)" +→ Inaccurate! Maybe it was 8mm or 0mm + +Bad Approach (local only): +"Tomorrow will probably be... uh... like today?" +→ Inaccurate! Weather changes + +Hybrid Approach: +"It rained EXACTLY 5.2mm on Jan 16 (measured!)" +"It will rain about 3mm on Jan 22 (forecast)" +→ Best available data for optimal irrigation! +``` + +--- + +## 🔍 Confusing Terms Clarified + +| Term | What Some Think | What It REALLY Means | +|------|-----------------|----------------------| +| **"Local"** | Only past | Past + CURRENT + Today | +| **"Historical"** | Only old data | Past + Today up to now | +| **"Current"** | Just 1 data point | Average of last 24h | +| **"Forecast"** | Everything after now | ONLY from tomorrow (today = local!) | + +--- + +## 📋 Cheat Sheet for Developers + +```typescript +// ❓ When is what called? + +// Mobile app shows current weather: +→ getWeatherDataInternal() + → LocalProvider.getWeatherDataInternal() + → Returns 1 WeatherData object (NOW) + +// OpenSprinkler checks for rain delay: +→ getWeatherDataInternal() + → checks: data.raining === true? + → Source: Last 24h from local station + +// Zimmerman calculates watering scale: +→ getWateringDataWithForecastProvider("OpenMeteo") + → LocalProvider.getWateringDataInternal() + → Returns array of 8 WateringData (7 days + today) + → OpenMeteoProvider.getWateringDataInternal() + → Returns array of 7 WateringData (tomorrow through +7) + → Combined into 15 WateringData + → Zimmerman analyzes all 15 days +``` + +--- + +## 🐛 Common Misunderstandings + +### ❌ WRONG: +> "Hybrid uses Local only for past, Forecast for today" + +### ✅ CORRECT: +> "Hybrid uses Local for past AND today, Forecast only from tomorrow" + +--- + +### ❌ WRONG: +> "Current Weather comes from Forecast Provider" + +### ✅ CORRECT: +> "Current Weather always comes from local station (except fallback)" + +--- + +### ❌ WRONG: +> "Historical Data ends yesterday at midnight" + +### ✅ CORRECT: +> "Historical Data includes today from 00:00 to now" + +--- + +## 💡 For Pull Request / Documentation + +When changing comments in the code, make sure you: + +1. ✅ **Clearly separate three data types:** Current, Historical, Forecast +2. ✅ **Define time periods precisely:** "NOW", "last 7 days + today", "tomorrow through +7" +3. ✅ **Specify sources:** "Local Station", "Forecast Provider" +4. ✅ **Explain use cases:** "Rain Delay", "Zimmerman", "App Display" +5. ✅ **Give examples:** With real timestamps and values + +**Avoid vague terms like:** +- ❌ "Historical" (without saying today is included) +- ❌ "Past" (without time range) +- ❌ "Local data" (without saying current + historical) + +**Use precise terms:** +- ✅ "Past 7 days + today (00:00 to now)" +- ✅ "Current conditions (last 24 hours)" +- ✅ "Tomorrow through +7 days" + +--- + +## 🎉 Summary + +**Hybrid Weather Provider = Three data sources optimally combined:** + +1. **Now (Current):** Your station measures LIVE → Rain delay works +2. **Yesterday + Today:** Your station MEASURED → Precise history +3. **Tomorrow + Future:** Professionals PREDICTED → Good planning + += **Best irrigation decisions!** 💧🌱 + +--- + +## 📖 Additional Notes + +### Why Today is Split (00:00 to now vs now to 23:59)? + +**Morning Scenario (10:00 AM):** +- Local station has data from 00:00 to 10:00 (10 hours of measurements) +- This is REAL data that already happened +- Forecast provider might have predicted 0mm rain, but you actually got 5mm! +- Using local data ensures Zimmerman knows the ACTUAL conditions + +**Evening Scenario (10:00 PM):** +- Local station has data from 00:00 to 22:00 (22 hours of measurements) +- Only 2 hours left in the day +- Local data is nearly complete and highly accurate +- Much better than using a forecast made yesterday + +### Why Filter Forecast to Start Tomorrow? + +```typescript +// In hybrid.ts line 116: +const tomorrowEpoch = currentDayEpoch + (24 * 60 * 60); +forecastData = forecastResult.filter(data => + data.periodStartTime >= tomorrowEpoch // Only from tomorrow +); +``` + +**Reason:** Forecast providers often return data for "today" in their response, but: +1. Their "today" data was predicted yesterday (outdated) +2. Your local station has ACTUAL measurements for today +3. Mixing predicted and measured data for the same day causes confusion +4. Filtering ensures clean separation: Local = Past+Today, Forecast = Future + +### Error Handling + +The hybrid provider gracefully degrades: + +```typescript +// Best case: Local works + Forecast works +→ Returns 8 local days + 7 forecast days = 15 days total + +// Local fails, Forecast works: +→ Returns 0 local days + 7 forecast days = 7 days total +→ (Zimmerman might not have enough data) + +// Local works, Forecast fails: +→ Returns 8 local days + 0 forecast days = 8 days total +→ (Still enough for Zimmerman! Can work with local only) + +// Both fail: +→ Throws InsufficientWeatherData error +``` + +This means your irrigation can still work even if: +- Your internet goes down (local station keeps providing data) +- The forecast API is unavailable (local provides 8 days minimum) +- Your station goes offline temporarily (forecast provides predictions) + +--- + +## 🔧 Implementation Details + +### Cache Behavior + +```typescript +public shouldCacheWateringScale(): boolean { + return true; +} +``` + +**Why cache?** +- Historical data never changes (the past is fixed) +- Reduces load on local station +- Reduces API calls to forecast provider +- Cache expires at end of day (midnight) + +### Data Freshness + +| Data Type | Update Frequency | Cache Duration | +|-----------|------------------|----------------| +| Current Weather | Every 5-15 minutes (from PWS) | 24 hours | +| Historical Data | Once per day (at midnight) | Until midnight | +| Forecast Data | Every 1-6 hours (from provider) | Per provider settings | + +### Minimum Data Requirements + +For Zimmerman to work, you need: +- At least 23 hours of continuous data +- All required fields: temp, humidity, precip, solar, wind +- At least 1 day of data (preferably 7-14 days) + +The hybrid provider ensures this by: +1. Checking data span in local.ts (line 80) +2. Requiring all metrics for each day (line 118, 163) +3. Graceful degradation if forecast unavailable + +--- + +## 🎯 Best Practices + +### For OpenSprinkler Users: + +1. **Use OpenMeteo as forecast provider** + - Free, unlimited, reliable + - All fields complete (no errCode: 11) + - Better for irrigation than Apple + +2. **Ensure LOCAL_PERSISTENCE=true** + - Required for data to persist across restarts + - Saves observations.json to disk every 30 minutes + +3. **Let system collect data for 24+ hours** + - First day: Limited functionality + - After 24 hours: Full Zimmerman calculations + - After 7 days: Optimal multi-day analysis + +4. **Monitor your station's data quality** + - Check observations.json periodically + - Ensure all sensors working (temp, humidity, rain, solar, wind) + - Missing sensors will cause days to be skipped + +### For Developers: + +1. **Always check error codes** + - errCode: 10 = InsufficientWeatherData (not enough data) + - errCode: 11 = MissingWeatherField (incomplete response) + - Handle both gracefully + +2. **Log data sources clearly** + - Use `[HybridWeather]`, `[LocalWeather]` prefixes + - Include counts: "Retrieved 8 days from local" + - Help users debug issues + +3. **Test edge cases** + - Station offline for 12 hours + - Forecast API down + - Partial day data (early morning) + - All sensors vs missing sensors + +4. **Document time zones** + - Use `localTime(coordinates)` consistently + - Explain that "today" is in user's local time + - Avoid UTC confusion + +--- + +## 📚 Further Reading + +- **Zimmerman Algorithm:** See `ZimmermanAdjustmentMethod.ts` +- **Local Weather Provider:** See `local.ts` for data collection +- **Weather Underground Protocol:** See `docs/pws-protocol.md` +- **WeeWX Integration:** See `docs/weewx.md` diff --git a/package.json b/package.json index 894cc8d..5709912 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "os-weather-service", "description": "OpenSprinkler Weather Service", - "version": "3.0.2", + "version": "3.1.0-b10-11", "repository": "https://github.com/OpenSprinkler/Weather-Weather", "scripts": { "test": "mocha --exit --require ts-node/register **/*.spec.ts", diff --git a/src/errors.ts b/src/errors.ts index f76bbc9..9c2271c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -83,7 +83,7 @@ export function makeCodedError( err: any ): CodedError { if ( err instanceof CodedError ) { return err; } else { - console.error("Unexpected error:", err); + console.error("Unexpected error:", err); return new CodedError( ErrorCode.UnexpectedError ); } } diff --git a/src/routes/geocoders/Geocoder.ts b/src/routes/geocoders/Geocoder.ts index 172a41d..6b6ae46 100644 --- a/src/routes/geocoders/Geocoder.ts +++ b/src/routes/geocoders/Geocoder.ts @@ -6,11 +6,19 @@ import { CodedError, ErrorCode } from "../../errors"; export abstract class Geocoder { - private static cacheFile = process.env.GEOCODER_CACHE_FILE || path.join(__dirname, "..", "geocoderCache.json"); + // Use same data directory as other persistent data + // Using PERSISTENCE_LOCATION as per PR #144, but with path.join() for cross-platform compatibility (Copilot suggestion) + private static dataDir = process.env.PERSISTENCE_LOCATION || path.join(__dirname, '..', '..', 'data'); + private static cacheFile = path.join(Geocoder.dataDir, 'geocoderCache.json'); private cache: Map; public constructor() { + // Ensure data directory exists + if (!fs.existsSync(Geocoder.dataDir)) { + fs.mkdirSync(Geocoder.dataDir, { recursive: true }); + } + // Load the cache from disk. if ( fs.existsSync( Geocoder.cacheFile ) ) { this.cache = new Map( JSON.parse( fs.readFileSync( Geocoder.cacheFile, "utf-8" ) ) ); @@ -25,7 +33,15 @@ export abstract class Geocoder { } private saveCache(): void { - fs.writeFileSync( Geocoder.cacheFile, JSON.stringify( Array.from( this.cache.entries() ) ) ); + try { + // Ensure data directory exists before writing + if (!fs.existsSync(Geocoder.dataDir)) { + fs.mkdirSync(Geocoder.dataDir, { recursive: true }); + } + fs.writeFileSync( Geocoder.cacheFile, JSON.stringify( Array.from( this.cache.entries() ) ) ); + } catch (err) { + console.error("Error saving geocoder cache:", err); + } } /** @@ -63,4 +79,4 @@ export abstract class Geocoder { throw ex; } } -} +} \ No newline at end of file diff --git a/src/routes/weather.ts b/src/routes/weather.ts index 167d1cd..0814510 100644 --- a/src/routes/weather.ts +++ b/src/routes/weather.ts @@ -32,6 +32,7 @@ import DWDWeatherProvider from "./weatherProviders/DWD"; import LocalWeatherProvider from "./weatherProviders/local"; import OpenMeteoWeatherProvider from "./weatherProviders/OpenMeteo"; import PirateWeatherWeatherProvider from "./weatherProviders/PirateWeather"; +import HybridWeatherProvider from "./weatherProviders/hybrid"; import GoogleMapsGeocoder from "./geocoders/GoogleMaps"; import WUndergroundGeocoder from "./geocoders/WUnderground"; import { TZDate } from "@date-fns/tz"; @@ -47,6 +48,13 @@ const WEATHER_PROVIDERS: { [K in Exclude]: Weath WU: new WUndergroundWeatherProvider(), }; +// Initialize Hybrid provider with access to all forecast providers (excluding 'local') +const HYBRID_PROVIDER = new HybridWeatherProvider( + new Map( + Object.entries(WEATHER_PROVIDERS).filter(([key]) => key !== 'local') + ) +); + const GEOCODERS: { [name: string]: Geocoder } = { GoogleMaps: new GoogleMapsGeocoder(), WU: new WUndergroundGeocoder(), @@ -176,20 +184,52 @@ function getTimeData(coordinates: GeoCoordinates): TimeData { }; } + +/** + * Helper function to detect if wateringData contains future forecast data. + * This is used to determine if we're in Hybrid Mode with combined local+forecast data. + * + * @param wateringData Array of watering data to check + * @returns true if array contains data for future days (tomorrow onwards) + */ +function hasFutureDaysInWateringData(wateringData?: readonly WateringData[]): boolean { + if (!wateringData || wateringData.length === 0) { + return false; + } + + // Calculate start of tomorrow (midnight UTC) + const nowUtc = new Date(); + const tomorrowMidnightUtc = new Date(Date.UTC( + nowUtc.getUTCFullYear(), + nowUtc.getUTCMonth(), + nowUtc.getUTCDate() + 1, + 0, 0, 0 + )); + const tomorrowEpoch = Math.floor(tomorrowMidnightUtc.getTime() / 1000); + + // Check if array contains any future days + return wateringData.some(data => data.periodStartTime >= tomorrowEpoch); +} + /** * Checks if the weather data meets any of the restrictions set by OpenSprinkler. Restrictions prevent any watering * from occurring and are similar to 0% watering level. Known restrictions are: * - * - California watering restriction prevents watering if precipitation over two days is greater than 0.1" over the past - * 48 hours. + * - Rain amount restriction: Prevents watering if forecasted precipitation over N days exceeds threshold. + * In Hybrid Mode, uses combined local+forecast data from wateringData array. + * In Standard Mode, uses weather.forecast array. + * - California restriction: Prevents watering if precipitation over two days is greater than 0.1" over the past 48 hours. + * - Temperature restriction: Prevents watering if current temperature is below minimum threshold. + * * @param cali A boolean to enable the California restriction based on the old method. * @param wateringData Watering data to use to determine if any restrictions apply. * @param adjustmentOptions The adjustment options used, which gives restriction information. - * @param weather Weather data to use to determine if any restrictions apply. + * @param weather Weather data to use to determine if any restrictions apply (used in Standard Mode). * @return A boolean indicating if the watering level should be set to 0% due to a restriction. */ function checkWeatherRestriction( cali: boolean, wateringData?: readonly WateringData[], adjustmentOptions?: AdjustmentOptions, weather?: WeatherData ): boolean { + // California restriction: Check past 48 hours if ( ( cali || (adjustmentOptions && adjustmentOptions.cali ) ) && wateringData && wateringData.length ) { // The most recent two days are at the beginning of the data array const len = wateringData.length; @@ -203,20 +243,71 @@ function checkWeatherRestriction( cali: boolean, wateringData?: readonly Waterin } } + // Rain amount restriction: Check forecasted rain + // AUTO-DETECT MODE: Use wateringData if it contains future days (Hybrid Mode), + // otherwise use weather.forecast (Standard Mode) if ( adjustmentOptions.rainAmt && adjustmentOptions.rainAmt > 0 && adjustmentOptions.rainDays ) { - const days = weather.forecast.length > adjustmentOptions.rainDays ? adjustmentOptions.rainDays : weather.forecast.length; - let precip = 0; - for ( let i = 0; i < days; i++ ) { - precip += weather.forecast[i].precip; - } - if ( precip > adjustmentOptions.rainAmt ){ - return true; + // Check if we have future forecast data in wateringData (Hybrid Mode) + const isHybridMode = hasFutureDaysInWateringData(wateringData); + + if (isHybridMode) { + // HYBRID MODE: Use wateringData which contains local historical + cloud forecast + console.log('[Weather Restriction] Hybrid Mode detected - using combined wateringData for forecast check'); + + // Calculate start of tomorrow (midnight UTC) + const nowUtc = new Date(); + const tomorrowMidnightUtc = new Date(Date.UTC( + nowUtc.getUTCFullYear(), + nowUtc.getUTCMonth(), + nowUtc.getUTCDate() + 1, + 0, 0, 0 + )); + const tomorrowEpoch = Math.floor(tomorrowMidnightUtc.getTime() / 1000); + + // Filter to only future days (tomorrow onwards) + const futureDays = wateringData.filter(data => data.periodStartTime >= tomorrowEpoch); + + // Take only the requested number of days + const daysToCheck = Math.min(futureDays.length, adjustmentOptions.rainDays); + + console.log(`[Weather Restriction] Checking ${daysToCheck} future days for rain (threshold: ${adjustmentOptions.rainAmt}")`); + + let precip = 0; + for ( let i = 0; i < daysToCheck; i++ ) { + precip += futureDays[i].precip; + console.log(`[Weather Restriction] Day ${i+1}: +${futureDays[i].precip}" (total: ${precip}")`); + } + + if ( precip > adjustmentOptions.rainAmt ) { + console.log(`[Weather Restriction] TRIGGERED: ${precip}" > ${adjustmentOptions.rainAmt}"`); + return true; + } + + } else { + // STANDARD MODE: Use weather.forecast (backwards-compatible behavior) + if (!weather || !weather.forecast || weather.forecast.length === 0) { + console.warn('[Weather Restriction] No forecast data available for rain amount check'); + return false; + } + + console.log('[Weather Restriction] Standard Mode - using weather.forecast'); + + const days = weather.forecast.length > adjustmentOptions.rainDays ? adjustmentOptions.rainDays : weather.forecast.length; + let precip = 0; + for ( let i = 0; i < days; i++ ) { + precip += weather.forecast[i].precip; + } + + if ( precip > adjustmentOptions.rainAmt ){ + return true; + } } } + // Temperature restriction: Check current temperature if ( typeof adjustmentOptions.minTemp !== "undefined" && adjustmentOptions.minTemp != -40 ) { - if ( weather.temp < adjustmentOptions.minTemp ) { + if ( weather && weather.temp < adjustmentOptions.minTemp ) { return true; } } @@ -224,6 +315,7 @@ function checkWeatherRestriction( cali: boolean, wateringData?: readonly Waterin return false; } + export const getWeatherData = async function( req: express.Request, res: express.Response ) { const location: string = getParameter(req.query.loc); let adjustmentOptionsString: string = getParameter(req.query.wto), @@ -382,13 +474,25 @@ export const getWateringData = async function( req: express.Request, res: expres let weatherProvider: WeatherProvider; let provider: string = adjustmentOptions.provider; - if ( !provider && process.env.WEATHER_PROVIDER ) { + // Check if hybrid mode is enabled via environment variable + const isHybridMode = process.env.WEATHER_PROVIDER === 'hybrid'; + + // In hybrid mode, don't fall back to env.WEATHER_PROVIDER for forecast provider + if ( !provider && process.env.WEATHER_PROVIDER && !isHybridMode ) { provider = process.env.WEATHER_PROVIDER; } - if( pws && pws.id ){ + if (isHybridMode) { + // HYBRID MODE: Use local for historical, App UI selection for forecast + weatherProvider = HYBRID_PROVIDER; + // Use provider from App, or default to Apple (never use 'hybrid' as provider name) + const forecastProvider = provider && provider !== 'hybrid' ? provider : 'Apple'; + console.log(`[Weather] Hybrid mode active. Forecast provider: ${forecastProvider}`); + } else if (pws && pws.id) { + // Standard PWS mode weatherProvider = PWS_WEATHER_PROVIDER; - }else{ + } else { + // Standard single-provider mode if (typeof WEATHER_PROVIDERS[provider] === 'object') { weatherProvider = WEATHER_PROVIDERS[provider]; } else { @@ -414,6 +518,22 @@ export const getWateringData = async function( req: express.Request, res: expres // Calculate the watering scale if it wasn't found in the cache. let adjustmentMethodResponse: AdjustmentMethodResponse; try { + if (isHybridMode && weatherProvider instanceof HybridWeatherProvider) { + // HYBRID MODE: Get forecast provider from App UI selection + // Never use 'hybrid' as provider name - default to Apple + const forecastProvider = provider && provider !== 'hybrid' ? provider : 'Apple'; + + console.log(`[Weather] Fetching hybrid data: local (historical) + ${forecastProvider} (forecast)`); + + // Fetch combined watering data before calling adjustment method + await weatherProvider.getWateringDataWithForecastProvider( + coordinates, + pws, + forecastProvider + ); + } + + // Call adjustment method (works for both hybrid and standard modes) adjustmentMethodResponse = await adjustmentMethod.calculateWateringScale( adjustmentOptions, coordinates, weatherProvider, pws ); @@ -431,6 +551,15 @@ export const getWateringData = async function( req: express.Request, res: expres if ( checkRestrictions ) { let wateringData: readonly WateringData[] = adjustmentMethodResponse.wateringData; + + // DEBUG: Log wateringData content + console.log(`[Debug] wateringData from adjustmentMethod: ${wateringData ? wateringData.length + ' entries' : 'undefined'}`); + if (wateringData && wateringData.length > 0) { + const sortedByTime = [...wateringData].sort((a, b) => a.periodStartTime - b.periodStartTime); + console.log(`[Debug] Oldest entry: epoch=${sortedByTime[0].periodStartTime}, precip=${sortedByTime[0].precip}`); + console.log(`[Debug] Newest entry: epoch=${sortedByTime[sortedByTime.length-1].periodStartTime}, precip=${sortedByTime[sortedByTime.length-1].precip}`); + } + let dataArr: CachedResult; // Fetch the watering data if the AdjustmentMethod didn't fetch it and the california restriction is being checked. if ( ( ( ( wateringParam >> 7 ) & 1 ) > 0 || ( typeof adjustmentOptions.cali !== "undefined" && adjustmentOptions.cali ) ) && !adjustmentMethodResponse.wateringData ) { @@ -645,4 +774,4 @@ export function keyToUse(defaultKey: string, pws: PWS): string { } else { throw new CodedError(ErrorCode.NoAPIKeyProvided); } -} +} \ No newline at end of file diff --git a/src/routes/weatherProviders/AccuWeather.ts b/src/routes/weatherProviders/AccuWeather.ts index 1f13c38..5132888 100644 --- a/src/routes/weatherProviders/AccuWeather.ts +++ b/src/routes/weatherProviders/AccuWeather.ts @@ -47,7 +47,7 @@ export default class AccuWeatherWeatherProvider extends WeatherProvider { const cloudCoverInfo: CloudCoverInfo[] = historicData.map( ( hour ): CloudCoverInfo => { //return empty interval if measurement does not exist - const time = fromUnixTime( hour.EpochTime, {in: tz(getTZ(coordinates))} ); + const time = fromUnixTime( hour.EpochTime, {in: tz(getTZ(coordinates))} ); if(hour.CloudCover === undefined ){ return { startTime: time, diff --git a/src/routes/weatherProviders/Apple.ts b/src/routes/weatherProviders/Apple.ts index 33f98c4..73f02b9 100644 --- a/src/routes/weatherProviders/Apple.ts +++ b/src/routes/weatherProviders/Apple.ts @@ -36,38 +36,38 @@ interface Metadata { } interface CurrentWeather { - name: string, - metadata: Metadata, - asOf: string; // Required; ISO 8601 date-time - cloudCover?: number; // Optional; 0 to 1 - conditionCode: string; // Required; enumeration of weather condition - daylight?: boolean; // Optional; indicates daylight - humidity: number; // Required; 0 to 1 - precipitationIntensity: number; // Required; in mm/h - pressure: number; // Required; in millibars - pressureTrend: PressureTrend; // Required; direction of pressure change - temperature: number; // Required; in °C - temperatureApparent: number; // Required; feels-like temperature in °C - temperatureDewPoint: number; // Required; in °C - uvIndex: number; // Required; UV radiation level - visibility: number; // Required; in meters - windDirection?: number; // Optional; in degrees - windGust?: number; // Optional; max wind gust speed in km/h - windSpeed: number; // Required; in km/h + name: string, + metadata: Metadata, + asOf: string; // Required; ISO 8601 date-time + cloudCover?: number; // Optional; 0 to 1 + conditionCode: string; // Required; enumeration of weather condition + daylight?: boolean; // Optional; indicates daylight + humidity: number; // Required; 0 to 1 + precipitationIntensity: number; // Required; in mm/h + pressure: number; // Required; in millibars + pressureTrend: PressureTrend; // Required; direction of pressure change + temperature: number; // Required; in °C + temperatureApparent: number; // Required; feels-like temperature in °C + temperatureDewPoint: number; // Required; in °C + uvIndex: number; // Required; UV radiation level + visibility: number; // Required; in meters + windDirection?: number; // Optional; in degrees + windGust?: number; // Optional; max wind gust speed in km/h + windSpeed: number; // Required; in km/h } interface DayPartForecast { - cloudCover: number; // Required; 0 to 1 - conditionCode: string; // Required; enumeration of weather condition - forecastEnd: string; // Required; ISO 8601 date-time - forecastStart: string; // Required; ISO 8601 date-time - humidity: number; // Required; 0 to 1 - precipitationAmount: number; // Required; in millimeters - precipitationChance: number; // Required; as a percentage - precipitationType: PrecipitationType; // Required - snowfallAmount: number; // Required; in millimeters - windDirection?: number; // Optional; in degrees - windSpeed: number; // Required; in km/h + cloudCover: number; // Required; 0 to 1 + conditionCode: string; // Required; enumeration of weather condition + forecastEnd: string; // Required; ISO 8601 date-time + forecastStart: string; // Required; ISO 8601 date-time + humidity: number; // Required; 0 to 1 + precipitationAmount: number; // Required; in millimeters + precipitationChance: number; // Required; as a percentage + precipitationType: PrecipitationType; // Required + snowfallAmount: number; // Required; in millimeters + windDirection?: number; // Optional; in degrees + windSpeed: number; // Required; in km/h } interface DailyForecastData { @@ -99,9 +99,9 @@ interface DailyForecastData { } interface DailyForecast { - name: string, - metadata: Metadata, - days: DailyForecastData[]; + name: string, + metadata: Metadata, + days: DailyForecastData[]; } interface HourWeatherConditions { @@ -127,9 +127,9 @@ interface HourWeatherConditions { } interface HourlyForecast { - name: string, - metadata: Metadata, - hours: HourWeatherConditions[]; + name: string, + metadata: Metadata, + hours: HourWeatherConditions[]; } interface ForecastMinute { @@ -147,16 +147,16 @@ interface ForecastPeriodSummary { } interface NextHourForecast { - name: string, - metadata: Metadata, - forecastEnd?: string; // ISO 8601 date-time - forecastStart?: string; // ISO 8601 date-time - minutes: ForecastMinute[]; // Required; array of forecast minutes - summary: ForecastPeriodSummary[]; // Required; array of forecast summaries + name: string, + metadata: Metadata, + forecastEnd?: string; // ISO 8601 date-time + forecastStart?: string; // ISO 8601 date-time + minutes: ForecastMinute[]; // Required; array of forecast minutes + summary: ForecastPeriodSummary[]; // Required; array of forecast summaries } interface WeatherAlertSummary { - areaId?: string; // Official designation of the affected area + areaId?: string; // Official designation of the affected area areaName?: string; // Human-readable name of the affected area certainty: Certainty; // Required; likelihood of the event countryCode: string; // Required; ISO country code @@ -175,9 +175,9 @@ interface WeatherAlertSummary { } interface WeatherAlertCollection { - name: string, - metadata: Metadata, - alerts: WeatherAlertSummary[]; + name: string, + metadata: Metadata, + alerts: WeatherAlertSummary[]; } interface AppleWeather { @@ -225,7 +225,7 @@ export default class AppleWeatherProvider extends WeatherProvider { ): Promise { const currentDay = startOfDay(localTime(coordinates)); - const tz = getTZ(coordinates); + const tz = getTZ(coordinates); const startTimestamp = new Date(+subDays(currentDay, 10)).toISOString(); const endTimestamp = new Date(+currentDay).toISOString(); @@ -280,7 +280,7 @@ export default class AppleWeatherProvider extends WeatherProvider { const cloudCoverInfo: CloudCoverInfo[] = daysInHours[i].map( (hour): CloudCoverInfo => { - const startTime = new TZDate(hour.forecastStart, tz); + const startTime = new TZDate(hour.forecastStart, tz); return { startTime, @@ -347,7 +347,7 @@ export default class AppleWeatherProvider extends WeatherProvider { coordinates: GeoCoordinates, pws: PWS | undefined ): Promise { - const tz = getTZ(coordinates); + const tz = getTZ(coordinates); const forecastUrl = `https://weatherkit.apple.com/api/v1/weather/en/${coordinates[0]}/${coordinates[1]}?dataSets=currentWeather,forecastDaily&timezone=${tz}`; diff --git a/src/routes/weatherProviders/DWD.ts b/src/routes/weatherProviders/DWD.ts index e7da02e..c84fec1 100644 --- a/src/routes/weatherProviders/DWD.ts +++ b/src/routes/weatherProviders/DWD.ts @@ -13,11 +13,11 @@ export default class DWDWeatherProvider extends WeatherProvider { } protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { - const tz = getTZ(coordinates); + const tz = getTZ(coordinates); const currentDay = startOfDay(localTime(coordinates)); - const startTimestamp = subDays(currentDay, 7).toISOString(); - const endTimestamp = currentDay.toISOString(); + const startTimestamp = subDays(currentDay, 7).toISOString(); + const endTimestamp = currentDay.toISOString(); const historicUrl = `https://api.brightsky.dev/weather?lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }&date=${ startTimestamp }&last_date=${ endTimestamp }&tz=${tz}` @@ -52,7 +52,7 @@ export default class DWDWeatherProvider extends WeatherProvider { for(let i = 0; i < daysInHours.length; i++){ const cloudCoverInfo: CloudCoverInfo[] = daysInHours[i].map( ( hour ): CloudCoverInfo => { - const startTime = new TZDate(hour.timestamp, tz); + const startTime = new TZDate(hour.timestamp, tz); const result : CloudCoverInfo = { startTime, endTime: addHours(startTime, 1), @@ -151,7 +151,7 @@ export default class DWDWeatherProvider extends WeatherProvider { forecast: [], }; - const local = localTime(coordinates); + const local = localTime(coordinates); for ( let day = 0; day < 7; day++ ) { diff --git a/src/routes/weatherProviders/OWM.ts b/src/routes/weatherProviders/OWM.ts index ca9343f..01713e1 100644 --- a/src/routes/weatherProviders/OWM.ts +++ b/src/routes/weatherProviders/OWM.ts @@ -20,7 +20,7 @@ export default class OWMWeatherProvider extends WeatherProvider { const localKey = keyToUse(this.API_KEY, pws); //Get previous date by using UTC - const yesterday = subDays(startOfDay(localTime(coordinates)), 1); + const yesterday = subDays(startOfDay(localTime(coordinates)), 1); const yesterdayUrl = `https://api.openweathermap.org/data/3.0/onecall/day_summary?units=imperial&appid=${ localKey }&lat=${ coordinates[ 0 ] }&lon=${ coordinates[ 1 ] }&date=${format(yesterday, "yyyy-MM-dd")}&tz=${format(yesterday, "xxx")}`; @@ -41,7 +41,7 @@ export default class OWMWeatherProvider extends WeatherProvider { let clouds = (new Array(24)).fill(historicData.cloud_cover.afternoon); const cloudCoverInfo: CloudCoverInfo[] = clouds.map( ( sample, i ): CloudCoverInfo => { - const start = addHours(yesterday, i); + const start = addHours(yesterday, i); if( sample === undefined ) { return { startTime: start, diff --git a/src/routes/weatherProviders/OpenMeteo.ts b/src/routes/weatherProviders/OpenMeteo.ts index 6164c59..eeda65a 100755 --- a/src/routes/weatherProviders/OpenMeteo.ts +++ b/src/routes/weatherProviders/OpenMeteo.ts @@ -16,10 +16,10 @@ export default class OpenMeteoWeatherProvider extends WeatherProvider { protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { const tz = getTZ(coordinates); - const currentDay = startOfDay(localTime(coordinates)); + const currentDay = startOfDay(localTime(coordinates)); - const startTimestamp = format(subDays(currentDay, 7), "yyyy-MM-dd"); - const endTimestamp = format(currentDay, "yyyy-MM-dd"); + const startTimestamp = format(subDays(currentDay, 7), "yyyy-MM-dd"); + const endTimestamp = format(currentDay, "yyyy-MM-dd"); const historicUrl = `https://api.open-meteo.com/v1/forecast?latitude=${ coordinates[ 0 ] }&longitude=${ coordinates[ 1 ] }&hourly=temperature_2m,relativehumidity_2m,precipitation,direct_radiation,windspeed_10m&temperature_unit=fahrenheit&windspeed_unit=mph&precipitation_unit=inch&start_date=${startTimestamp}&end_date=${endTimestamp}&timezone=${tz}&timeformat=unixtime`; diff --git a/src/routes/weatherProviders/PirateWeather.ts b/src/routes/weatherProviders/PirateWeather.ts index 9d88c68..d1d6219 100644 --- a/src/routes/weatherProviders/PirateWeather.ts +++ b/src/routes/weatherProviders/PirateWeather.ts @@ -16,7 +16,7 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { // The Unix timestamp of 24 hours ago. - const yesterday = subDays(startOfDay(localTime(coordinates)), 1); + const yesterday = subDays(startOfDay(localTime(coordinates)), 1); const localKey = keyToUse(this.API_KEY, pws); @@ -49,7 +49,7 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { samples = samples.slice(0,24); const cloudCoverInfo: CloudCoverInfo[] = samples.map( ( hour ): CloudCoverInfo => { - const startTime = fromUnixTime(hour.time); + const startTime = fromUnixTime(hour.time); return { startTime, endTime: addDays(startTime, 1), @@ -68,7 +68,7 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { */ temp += hour.temperature; - const currentHumidity = hour.humidity || this.humidityFromDewPoint(hour.temperature, hour.dewPoint); + const currentHumidity = hour.humidity || this.humidityFromDewPoint(hour.temperature, hour.dewPoint); humidity += currentHumidity; // This field may be missing from the response if it is snowing. precip += hour.precipAccumulation || 0; @@ -173,7 +173,7 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { } } - private celsiusToFahrenheit(celsius: number): number { + private celsiusToFahrenheit(celsius: number): number { return (celsius * 9) / 5 + 32; } @@ -185,30 +185,30 @@ export default class PirateWeatherWeatherProvider extends WeatherProvider { return kph * 0.621371; } - //https://www.npl.co.uk/resources/q-a/dew-point-and-relative-humidity - private eLn(temperature: number, a: number, b: number): number { - return Math.log(611.2) + ((a * temperature) / (b + temperature)); - } + //https://www.npl.co.uk/resources/q-a/dew-point-and-relative-humidity + private eLn(temperature: number, a: number, b: number): number { + return Math.log(611.2) + ((a * temperature) / (b + temperature)); + } - private eWaterLn(temperature: number): number { - return this.eLn(temperature, 17.62, 243.12); - } - private eIceLn(temperature: number): number { - return this.eLn(temperature, 22.46, 272.62); - } + private eWaterLn(temperature: number): number { + return this.eLn(temperature, 17.62, 243.12); + } + private eIceLn(temperature: number): number { + return this.eLn(temperature, 22.46, 272.62); + } - private humidityFromDewPoint(temperature: number, dewPoint: number): number { - if (isNaN(temperature)) return temperature; - if (isNaN(dewPoint)) return dewPoint; + private humidityFromDewPoint(temperature: number, dewPoint: number): number { + if (isNaN(temperature)) return temperature; + if (isNaN(dewPoint)) return dewPoint; - let eFn: (temp: number) => number; + let eFn: (temp: number) => number; - if (temperature > 0) { - eFn = (temp: number) => this.eWaterLn(temp); - } else { - eFn = (temp: number) => this.eIceLn(temp); - } + if (temperature > 0) { + eFn = (temp: number) => this.eWaterLn(temp); + } else { + eFn = (temp: number) => this.eIceLn(temp); + } - return 100 * Math.exp(eFn(dewPoint) - eFn(temperature)); - } + return 100 * Math.exp(eFn(dewPoint) - eFn(temperature)); + } } diff --git a/src/routes/weatherProviders/WeatherProvider.ts b/src/routes/weatherProviders/WeatherProvider.ts index a7102c1..5840873 100644 --- a/src/routes/weatherProviders/WeatherProvider.ts +++ b/src/routes/weatherProviders/WeatherProvider.ts @@ -66,22 +66,22 @@ export class WeatherProvider { return pws?.id || `${coordinates[0]};s${coordinates[1]}` } - /** - * Internal command to get the weather data from an API, will be cached when anything outside calls it - * @param coordinates Coordinates of requested data - * @param pws PWS data which includes the apikey - * @returns Returns weather data (should not be mutated) - */ + /** + * Internal command to get the weather data from an API, will be cached when anything outside calls it + * @param coordinates Coordinates of requested data + * @param pws PWS data which includes the apikey + * @returns Returns weather data (should not be mutated) + */ protected async getWeatherDataInternal(coordinates: GeoCoordinates, pws: PWS | undefined): Promise { throw "Selected WeatherProvider does not support getWeatherData"; } - /** - * Internal command to get the watering data from an API, will be cached when anything outside calls it - * @param coordinates Coordinates of requested data - * @param pws PWS data which includes the apikey - * @returns Returns watering data array in reverse chronological order (array should not be mutated) - */ + /** + * Internal command to get the watering data from an API, will be cached when anything outside calls it + * @param coordinates Coordinates of requested data + * @param pws PWS data which includes the apikey + * @returns Returns watering data array in reverse chronological order (array should not be mutated) + */ protected async getWateringDataInternal(coordinates: GeoCoordinates, pws: PWS | undefined): Promise { throw new CodedError( ErrorCode.UnsupportedAdjustmentMethod ); } diff --git a/src/routes/weatherProviders/hybrid-providers/BaseHybridProvider.ts b/src/routes/weatherProviders/hybrid-providers/BaseHybridProvider.ts new file mode 100644 index 0000000..9b6d096 --- /dev/null +++ b/src/routes/weatherProviders/hybrid-providers/BaseHybridProvider.ts @@ -0,0 +1,212 @@ +import { GeoCoordinates, WeatherData, WateringData, PWS } from "../../../types"; +import { WeatherProvider } from "../WeatherProvider"; +import LocalWeatherProvider from "../local"; +import { CodedError, ErrorCode } from "../../../errors"; +import { getUnixTime, startOfDay } from "date-fns"; +import { localTime } from "../../weather"; + +/** + * BaseHybridProvider - Abstract base class for all Hybrid weather providers. + * + * Design Philosophy: + * - CURRENT WEATHER (now): Uses local weather station for real-time conditions + * → Used for rain delay decisions and current weather display in app + * - HISTORICAL DATA (past 7 days): Uses local weather station for accurate measurements + * → Provides actual temperature, humidity, rainfall, solar, wind from your station + * - FORECAST DATA (next 7 days): Uses external provider (Apple, OpenMeteo, etc.) for predictions + * → Professional forecasts for future watering calculations + * + * Each concrete implementation (HybridOpenMeteoProvider, HybridAppleProvider, etc.) must implement: + * - getForecastData(): Method to fetch and convert forecast data from the specific cloud provider + */ +export abstract class BaseHybridProvider extends WeatherProvider { + protected localProvider: LocalWeatherProvider; + protected cloudProvider: WeatherProvider; + protected cloudProviderName: string; + + constructor(cloudProvider: WeatherProvider, cloudProviderName: string) { + super(); + this.localProvider = new LocalWeatherProvider(); + this.cloudProvider = cloudProvider; + this.cloudProviderName = cloudProviderName; + } + + /** + * Abstract method that each concrete hybrid provider must implement. + * This method is responsible for fetching forecast data from the specific cloud provider + * and converting it to the WateringData format. + * + * @param coordinates Geographic coordinates + * @param pws PWS information + * @param currentDayEpoch Unix timestamp for start of current day (to filter future data) + * @returns Array of WateringData for future days (tomorrow onwards) + */ + protected abstract getForecastData( + coordinates: GeoCoordinates, + pws: PWS | undefined, + currentDayEpoch: number + ): Promise; + + /** + * Get current weather data for display in the mobile app and rain delay decisions. + * + * This method returns REAL-TIME conditions from your local weather station. + * It ensures that current rain, temperature, and humidity are from actual measurements, + * not forecasts. This is critical for accurate rain delay activation. + * + * Data returned represents conditions RIGHT NOW (based on last 24 hours of observations). + * Falls back to forecast provider only if local station data is unavailable. + * + * @param coordinates Geographic coordinates + * @param pws PWS information + * @returns Current weather conditions from local station (or forecast fallback) + */ + protected async getWeatherDataInternal( + coordinates: GeoCoordinates, + pws: PWS | undefined + ): Promise { + let weatherData: WeatherData; + + try { + // Try to get current weather from local station first + // This ensures rain delay and current conditions use real measurements + weatherData = await this.localProvider.getWeatherDataInternal(coordinates, pws); + } catch (err) { + console.warn(`[Hybrid-${this.cloudProviderName}] Local weather data unavailable, falling back to cloud provider:`, err); + + // Fallback to cloud provider if local unavailable + weatherData = await this.cloudProvider.getWeatherDataInternal(coordinates, pws); + } + + // Override weatherProvider to show hybrid mode in UI + // Format: "local+OpenMeteo", "local+WU", etc. + weatherData.weatherProvider = `local+${this.cloudProviderName}`; + + return weatherData; + } + + /** + * Get watering data combining local historical measurements with external forecasts. + * + * This is the core method for Zimmerman watering calculations in hybrid mode. + * It combines the best of both worlds: + * + * LOCAL PWS (past + today): + * - Day -7 to Day -1: Complete days with actual measurements + * - Day 0 (today): Partial day with measurements up to current time + * → These are YOUR exact conditions: temp, humidity, rain, solar, wind + * + * FORECAST PROVIDER (future): + * - Day +1 to Day +7: Professional weather forecasts + * → Reliable predictions for upcoming week + * + * Example timeline (if called at 18:00 on Jan 18): + * - Jan 11-17: Your station's actual measurements (7 complete days) + * - Jan 18 (00:00-18:00): Your station's measurements for today so far + * - Jan 19-25: Forecast provider's predictions (7 future days) + * + * The Zimmerman algorithm uses all this data to calculate optimal watering. + * + * @param coordinates Geographic coordinates + * @param pws PWS information (used for local station) + * @returns Array of WateringData in reverse chronological order (newest first) + */ + protected async getWateringDataInternal( + coordinates: GeoCoordinates, + pws: PWS | undefined + ): Promise { + + const currentDay = startOfDay(localTime(coordinates)); + const currentDayEpoch = getUnixTime(currentDay); + + // 1. Get historical + today's data from local weather station + let historicalData: readonly WateringData[] = []; + let localDataAvailable = false; + + try { + const localResult = await this.localProvider.getWateringDataInternal(coordinates, pws); + + // Check if we actually got data (not just empty array) + if (localResult.length > 0) { + // Local provider returns past 7 days + today (partial day up to now) + // This is all MEASURED data from your station - keep all of it! + historicalData = localResult; + localDataAvailable = true; + + console.log(`[Hybrid-${this.cloudProviderName}] Retrieved ${historicalData.length} days of data from local station (including today)`); + } else { + console.warn(`[Hybrid-${this.cloudProviderName}] Local provider returned empty data set`); + localDataAvailable = false; + } + + } catch (err) { + console.warn(`[Hybrid-${this.cloudProviderName}] Local historical data unavailable:`, err); + localDataAvailable = false; + // Continue without local data - will use forecast for everything + } + + // 2. Get forecast data from cloud provider (implementation-specific) + let forecastData: readonly WateringData[] = []; + + try { + // Call the abstract method that each concrete provider implements + forecastData = await this.getForecastData(coordinates, pws, currentDayEpoch); + + console.log(`[Hybrid-${this.cloudProviderName}] Retrieved ${forecastData.length} days of forecast data (tomorrow onwards)`); + + } catch (err) { + console.warn(`[Hybrid-${this.cloudProviderName}] Forecast data unavailable:`, err); + + if (!localDataAvailable) { + throw new CodedError(ErrorCode.InsufficientWeatherData); + } + + // If we have local data but no forecast, just return local data + console.warn(`[Hybrid-${this.cloudProviderName}] Using only local historical data (forecast failed)`); + return historicalData; + } + + // 3. Check for data overlap and remove if necessary + if (localDataAvailable && historicalData.length > 0 && forecastData.length > 0) { + const latestHistorical = Math.max(...historicalData.map(d => d.periodStartTime)); + const earliestForecast = Math.min(...forecastData.map(d => d.periodStartTime)); + + if (earliestForecast <= latestHistorical) { + console.warn(`[Hybrid-${this.cloudProviderName}] Overlap detected! Latest historical: ${new Date(latestHistorical * 1000).toISOString()}, Earliest forecast: ${new Date(earliestForecast * 1000).toISOString()}`); + // Filter out any forecast data that overlaps with historical + forecastData = forecastData.filter(d => d.periodStartTime > latestHistorical); + console.log(`[Hybrid-${this.cloudProviderName}] After overlap removal: ${forecastData.length} forecast days`); + } + } + + // 4. Combine local measurements + forecast predictions + const combinedData = [...historicalData, ...forecastData]; + + if (combinedData.length === 0) { + console.error(`[Hybrid-${this.cloudProviderName}] No data available from either local or cloud providers`); + throw new CodedError(ErrorCode.InsufficientWeatherData); + } + + // 5. Sort by periodStartTime (newest first = reverse chronological) + // This is what the Zimmerman algorithm expects + combinedData.sort((a, b) => b.periodStartTime - a.periodStartTime); + + console.log(`[Hybrid-${this.cloudProviderName}] Combined data: ${historicalData.length} days (local+today) + ${forecastData.length} days (forecast) = ${combinedData.length} total days`); + console.log(`[Hybrid-${this.cloudProviderName}] Data sources: Local PWS (historical+today), ${this.cloudProviderName} (tomorrow+)`); + console.log(`[Hybrid-${this.cloudProviderName}] Date range: ${new Date(combinedData[combinedData.length-1].periodStartTime * 1000).toISOString().split('T')[0]} to ${new Date(combinedData[0].periodStartTime * 1000).toISOString().split('T')[0]}`); + + // Return as readonly array (TypeScript requirement) + return combinedData as readonly WateringData[]; + } + + /** + * Cache settings for hybrid provider. + * + * Historical data from local station doesn't change (past is past), + * so we can cache until end of day. Forecast data gets refreshed + * according to the forecast provider's own cache settings. + */ + public shouldCacheWateringScale(): boolean { + return true; + } +} \ No newline at end of file diff --git a/src/routes/weatherProviders/hybrid-providers/HybridAccuWeatherProvider.ts b/src/routes/weatherProviders/hybrid-providers/HybridAccuWeatherProvider.ts new file mode 100644 index 0000000..e06a708 --- /dev/null +++ b/src/routes/weatherProviders/hybrid-providers/HybridAccuWeatherProvider.ts @@ -0,0 +1,81 @@ +import { GeoCoordinates, WateringData, PWS } from "../../../types"; +import { WeatherProvider } from "../WeatherProvider"; +import { BaseHybridProvider } from "./BaseHybridProvider"; + +/** + * HybridAccuWeatherProvider - Combines local PWS data with AccuWeather forecasts. + * + * AccuWeather-specific implementation notes: + * - AccuWeather provides forecast data via getWeatherDataInternal() which includes a forecast array + * - We convert the daily forecast data to WateringData format + * - AccuWeather daily API provides: Temperature.Minimum/Maximum.Value, Rain.Value (day + night) + * - Missing data (humidity, solar, wind) gets reasonable defaults or undefined + * - NOTE: AccuWeather requires an API key + */ +export default class HybridAccuWeatherProvider extends BaseHybridProvider { + + constructor(cloudProvider: WeatherProvider) { + super(cloudProvider, "AccuWeather"); + } + + /** + * Get forecast data from AccuWeather and convert to WateringData format. + * + * AccuWeather returns forecast data through getWeatherDataInternal() in the forecast array. + * Each forecast day includes: EpochDate, temp_min, temp_max, precip (Day.Rain + Night.Rain) + * + * We filter to only include days AFTER today (tomorrow onwards) to avoid overlap + * with local historical data. + * + * @param coordinates Geographic coordinates + * @param pws PWS information (may contain API key) + * @param currentDayEpoch Unix timestamp for start of current day + * @returns Array of WateringData for future days + */ + protected async getForecastData( + coordinates: GeoCoordinates, + pws: PWS | undefined, + currentDayEpoch: number + ): Promise { + + console.log(`[Hybrid-AccuWeather] Fetching forecast data via WeatherData API`); + + // Get weather data which includes daily forecast + const weatherData = await this.cloudProvider.getWeatherDataInternal(coordinates, pws); + + // Convert forecast array to WateringData format + const allForecastData: WateringData[] = weatherData.forecast.map(day => ({ + weatherProvider: "AccuWeather", + periodStartTime: day.date, + temp: (day.temp_min + day.temp_max) / 2, // Average temperature + minTemp: day.temp_min, + maxTemp: day.temp_max, + humidity: 50, // Default - AccuWeather daily API doesn't provide hourly humidity averages + minHumidity: 40, // Reasonable defaults + maxHumidity: 60, + precip: day.precip, // Already combined (Day.Rain + Night.Rain) in AccuWeather provider + solarRadiation: undefined, // Not available in daily API + windSpeed: undefined // Not available in AccuWeather daily forecast API + })); + + console.log(`[Hybrid-AccuWeather DEBUG] Converted ${allForecastData.length} forecast days to WateringData`); + + if (allForecastData.length > 0) { + console.log(`[Hybrid-AccuWeather DEBUG] First entry periodStartTime: ${allForecastData[0].periodStartTime} (${new Date(allForecastData[0].periodStartTime * 1000).toISOString()})`); + console.log(`[Hybrid-AccuWeather DEBUG] Last entry periodStartTime: ${allForecastData[allForecastData.length-1].periodStartTime} (${new Date(allForecastData[allForecastData.length-1].periodStartTime * 1000).toISOString()})`); + } + + console.log(`[Hybrid-AccuWeather DEBUG] Current day epoch: ${currentDayEpoch} (${new Date(currentDayEpoch * 1000).toISOString()})`); + console.log(`[Hybrid-AccuWeather DEBUG] Tomorrow epoch: ${currentDayEpoch + (24*60*60)} (${new Date((currentDayEpoch + 24*60*60) * 1000).toISOString()})`); + + // Filter to only keep FUTURE forecast data (starting tomorrow) + const tomorrowEpoch = currentDayEpoch + (24 * 60 * 60); + const futureData = allForecastData.filter(data => + data.periodStartTime >= tomorrowEpoch + ); + + console.log(`[Hybrid-AccuWeather DEBUG] After filtering (>= tomorrow): ${futureData.length} entries`); + + return futureData; + } +} diff --git a/src/routes/weatherProviders/hybrid-providers/HybridAppleProvider.ts b/src/routes/weatherProviders/hybrid-providers/HybridAppleProvider.ts new file mode 100644 index 0000000..793ca1b --- /dev/null +++ b/src/routes/weatherProviders/hybrid-providers/HybridAppleProvider.ts @@ -0,0 +1,66 @@ +import { GeoCoordinates, WateringData, PWS } from "../../../types"; +import { WeatherProvider } from "../WeatherProvider"; +import { BaseHybridProvider } from "./BaseHybridProvider"; + +/** + * HybridAppleProvider - Combines local PWS data with Apple Weather forecasts. + * + * Apple Weather-specific implementation notes: + * - Apple provides forecast data via getWateringDataInternal() which returns WateringData[] + * - Unlike OpenMeteo, we can use the data directly without conversion + * - Apple's forecastDaily includes: temperatureMin, temperatureMax, precipitationAmount, humidity, windSpeed + * - Data is already in the correct WateringData format + */ +export default class HybridAppleProvider extends BaseHybridProvider { + + constructor(cloudProvider: WeatherProvider) { + super(cloudProvider, "Apple"); + } + + /** + * Get forecast data from Apple Weather. + * + * Apple Weather returns forecast data through getWateringDataInternal() already + * in WateringData format. We just need to filter to keep only future days. + * + * Apple's getWateringDataInternal() fetches both historical and forecast data, + * so we filter to only include days AFTER today to avoid overlap with local data. + * + * @param coordinates Geographic coordinates + * @param pws PWS information (not used by Apple but required by interface) + * @param currentDayEpoch Unix timestamp for start of current day + * @returns Array of WateringData for future days + */ + protected async getForecastData( + coordinates: GeoCoordinates, + pws: PWS | undefined, + currentDayEpoch: number + ): Promise { + + console.log(`[Hybrid-Apple] Fetching forecast data via getWateringDataInternal`); + + // Get watering data from Apple (includes past + future) + const allData = await this.cloudProvider.getWateringDataInternal(coordinates, pws); + + console.log(`[Hybrid-Apple DEBUG] Apple returned ${allData.length} total entries`); + + if (allData.length > 0) { + console.log(`[Hybrid-Apple DEBUG] First entry periodStartTime: ${allData[0].periodStartTime} (${new Date(allData[0].periodStartTime * 1000).toISOString()})`); + console.log(`[Hybrid-Apple DEBUG] Last entry periodStartTime: ${allData[allData.length-1].periodStartTime} (${new Date(allData[allData.length-1].periodStartTime * 1000).toISOString()})`); + } + + console.log(`[Hybrid-Apple DEBUG] Current day epoch: ${currentDayEpoch} (${new Date(currentDayEpoch * 1000).toISOString()})`); + console.log(`[Hybrid-Apple DEBUG] Tomorrow epoch: ${currentDayEpoch + (24*60*60)} (${new Date((currentDayEpoch + 24*60*60) * 1000).toISOString()})`); + + // Filter to only keep FUTURE forecast data (starting tomorrow) + // We exclude today because we already have real measurements from local PWS + const tomorrowEpoch = currentDayEpoch + (24 * 60 * 60); + const futureData = allData.filter(data => + data.periodStartTime >= tomorrowEpoch + ); + + console.log(`[Hybrid-Apple DEBUG] After filtering (>= tomorrow): ${futureData.length} entries`); + + return futureData; + } +} diff --git a/src/routes/weatherProviders/hybrid-providers/HybridDWDProvider.ts b/src/routes/weatherProviders/hybrid-providers/HybridDWDProvider.ts new file mode 100644 index 0000000..4c062ce --- /dev/null +++ b/src/routes/weatherProviders/hybrid-providers/HybridDWDProvider.ts @@ -0,0 +1,82 @@ +import { GeoCoordinates, WateringData, PWS } from "../../../types"; +import { WeatherProvider } from "../WeatherProvider"; +import { BaseHybridProvider } from "./BaseHybridProvider"; + +/** + * HybridDWDProvider - Combines local PWS data with DWD (Deutscher Wetterdienst) forecasts. + * + * DWD-specific implementation notes: + * - DWD provides forecast data via getWeatherDataInternal() which includes a forecast array + * - Uses Bright Sky API (https://api.brightsky.dev) as DWD data source + * - We convert the daily forecast data to WateringData format + * - DWD daily API provides: temperature min/max (converted from C to F), precipitation (converted from mm to inches) + * - Missing data (humidity, solar, wind) gets reasonable defaults or undefined + * - NOTE: DWD is free and requires NO API key (government-provided weather service for Germany) + */ +export default class HybridDWDProvider extends BaseHybridProvider { + + constructor(cloudProvider: WeatherProvider) { + super(cloudProvider, "DWD"); + } + + /** + * Get forecast data from DWD and convert to WateringData format. + * + * DWD returns forecast data through getWeatherDataInternal() in the forecast array. + * Each forecast day includes: date (Unix timestamp), temp_min, temp_max, precip (already converted to inches) + * + * We filter to only include days AFTER today (tomorrow onwards) to avoid overlap + * with local historical data. + * + * @param coordinates Geographic coordinates + * @param pws PWS information (not used by DWD but required by interface) + * @param currentDayEpoch Unix timestamp for start of current day + * @returns Array of WateringData for future days + */ + protected async getForecastData( + coordinates: GeoCoordinates, + pws: PWS | undefined, + currentDayEpoch: number + ): Promise { + + console.log(`[Hybrid-DWD] Fetching forecast data via WeatherData API`); + + // Get weather data which includes daily forecast (7 days from DWD) + const weatherData = await this.cloudProvider.getWeatherDataInternal(coordinates, pws); + + // Convert forecast array to WateringData format + const allForecastData: WateringData[] = weatherData.forecast.map(day => ({ + weatherProvider: "DWD", + periodStartTime: day.date, + temp: (day.temp_min + day.temp_max) / 2, // Average temperature + minTemp: day.temp_min, + maxTemp: day.temp_max, + humidity: 50, // Default - DWD daily API doesn't provide hourly humidity averages + minHumidity: 40, // Reasonable defaults + maxHumidity: 60, + precip: day.precip, // Already converted from mm to inches in DWD provider + solarRadiation: undefined, // Not available in daily API + windSpeed: undefined // Not available in DWD daily forecast API + })); + + console.log(`[Hybrid-DWD DEBUG] Converted ${allForecastData.length} forecast days to WateringData`); + + if (allForecastData.length > 0) { + console.log(`[Hybrid-DWD DEBUG] First entry periodStartTime: ${allForecastData[0].periodStartTime} (${new Date(allForecastData[0].periodStartTime * 1000).toISOString()})`); + console.log(`[Hybrid-DWD DEBUG] Last entry periodStartTime: ${allForecastData[allForecastData.length-1].periodStartTime} (${new Date(allForecastData[allForecastData.length-1].periodStartTime * 1000).toISOString()})`); + } + + console.log(`[Hybrid-DWD DEBUG] Current day epoch: ${currentDayEpoch} (${new Date(currentDayEpoch * 1000).toISOString()})`); + console.log(`[Hybrid-DWD DEBUG] Tomorrow epoch: ${currentDayEpoch + (24*60*60)} (${new Date((currentDayEpoch + 24*60*60) * 1000).toISOString()})`); + + // Filter to only keep FUTURE forecast data (starting tomorrow) + const tomorrowEpoch = currentDayEpoch + (24 * 60 * 60); + const futureData = allForecastData.filter(data => + data.periodStartTime >= tomorrowEpoch + ); + + console.log(`[Hybrid-DWD DEBUG] After filtering (>= tomorrow): ${futureData.length} entries`); + + return futureData; + } +} diff --git a/src/routes/weatherProviders/hybrid-providers/HybridOWMProvider.ts b/src/routes/weatherProviders/hybrid-providers/HybridOWMProvider.ts new file mode 100644 index 0000000..d320254 --- /dev/null +++ b/src/routes/weatherProviders/hybrid-providers/HybridOWMProvider.ts @@ -0,0 +1,82 @@ +import { GeoCoordinates, WateringData, PWS } from "../../../types"; +import { WeatherProvider } from "../WeatherProvider"; +import { BaseHybridProvider } from "./BaseHybridProvider"; + +/** + * HybridOWMProvider - Combines local PWS data with OpenWeatherMap forecasts. + * + * OWM-specific implementation notes: + * - OWM provides forecast data via getWeatherDataInternal() which includes a forecast array + * - We convert the daily forecast data to WateringData format + * - OWM daily API provides: temp.min, temp.max, rain (precip already converted from mm to inches) + * - Missing data (humidity, solar, wind) gets reasonable defaults or undefined + */ +export default class HybridOWMProvider extends BaseHybridProvider { + + constructor(cloudProvider: WeatherProvider) { + super(cloudProvider, "OWM"); + } + + /** + * Get forecast data from OWM and convert to WateringData format. + * + * OWM returns forecast data through getWeatherDataInternal() in the forecast array. + * Each forecast day includes: date, temp_min, temp_max, precip (already converted to inches) + * + * We filter to only include days AFTER today (tomorrow onwards) to avoid overlap + * with local historical data. + * + * @param coordinates Geographic coordinates + * @param pws PWS information (not used by OWM but required by interface) + * @param currentDayEpoch Unix timestamp for start of current day + * @returns Array of WateringData for future days + */ + protected async getForecastData( + coordinates: GeoCoordinates, + pws: PWS | undefined, + currentDayEpoch: number + ): Promise { + + console.log(`[Hybrid-OWM] Fetching forecast data via WeatherData API`); + + // Get weather data which includes daily forecast + const weatherData = await this.cloudProvider.getWeatherDataInternal(coordinates, pws); + + // Convert forecast array to WateringData format + const allForecastData: WateringData[] = weatherData.forecast.map(day => ({ + weatherProvider: "OWM", + periodStartTime: day.date, + temp: (day.temp_min + day.temp_max) / 2, // Average temperature + minTemp: day.temp_min, + maxTemp: day.temp_max, + humidity: 50, // Default - OWM daily API doesn't provide hourly humidity averages + minHumidity: 40, // Reasonable defaults + maxHumidity: 60, + precip: day.precip, // Already converted to inches in OWM provider + solarRadiation: undefined, // Not available in daily API + windSpeed: undefined // Not available in OWM daily API + })); + + console.log(`[Hybrid-OWM DEBUG] Converted ${allForecastData.length} forecast days to WateringData`); + + if (allForecastData.length > 0) { + console.log(`[Hybrid-OWM DEBUG] First entry periodStartTime: ${allForecastData[0].periodStartTime} (${new Date(allForecastData[0].periodStartTime * 1000).toISOString()})`); + console.log(`[Hybrid-OWM DEBUG] Last entry periodStartTime: ${allForecastData[allForecastData.length-1].periodStartTime} (${new Date(allForecastData[allForecastData.length-1].periodStartTime * 1000).toISOString()})`); + } + + console.log(`[Hybrid-OWM DEBUG] Current day epoch: ${currentDayEpoch} (${new Date(currentDayEpoch * 1000).toISOString()})`); + console.log(`[Hybrid-OWM DEBUG] Tomorrow epoch: ${currentDayEpoch + (24*60*60)} (${new Date((currentDayEpoch + 24*60*60) * 1000).toISOString()})`); + + // Filter to only keep FUTURE forecast data (starting tomorrow) + // We exclude today because we already have real measurements from local PWS + // This ensures today's data is always actual conditions, not predictions + const tomorrowEpoch = currentDayEpoch + (24 * 60 * 60); + const futureData = allForecastData.filter(data => + data.periodStartTime >= tomorrowEpoch + ); + + console.log(`[Hybrid-OWM DEBUG] After filtering (>= tomorrow): ${futureData.length} entries`); + + return futureData; + } +} diff --git a/src/routes/weatherProviders/hybrid-providers/HybridOpenMeteoProvider.ts b/src/routes/weatherProviders/hybrid-providers/HybridOpenMeteoProvider.ts new file mode 100644 index 0000000..6e84780 --- /dev/null +++ b/src/routes/weatherProviders/hybrid-providers/HybridOpenMeteoProvider.ts @@ -0,0 +1,88 @@ +import { GeoCoordinates, WateringData, PWS } from "../../../types"; +import { WeatherProvider } from "../WeatherProvider"; +import { BaseHybridProvider } from "./BaseHybridProvider"; +import { getUnixTime } from "date-fns"; + +/** + * HybridOpenMeteoProvider - Combines local PWS data with OpenMeteo forecasts. + * + * OpenMeteo-specific implementation notes: + * - OpenMeteo provides forecast data via getWeatherDataInternal() which includes a forecast array + * - We convert the daily forecast data to WateringData format + * - OpenMeteo daily API provides: temp_min, temp_max, precip + * - Missing data (humidity, solar, wind) gets reasonable defaults + */ +export default class HybridOpenMeteoProvider extends BaseHybridProvider { + + constructor(cloudProvider: WeatherProvider) { + super(cloudProvider, "OpenMeteo"); + } + + /** + * Get forecast data from OpenMeteo and convert to WateringData format. + * + * OpenMeteo returns forecast data through getWeatherDataInternal() in the forecast array. + * Each forecast day includes: date, temp_min, temp_max, precip + * + * We filter to only include days AFTER today (tomorrow onwards) to avoid overlap + * with local historical data. + * + * @param coordinates Geographic coordinates + * @param pws PWS information (not used by OpenMeteo but required by interface) + * @param currentDayEpoch Unix timestamp for start of current day + * @returns Array of WateringData for future days + */ + protected async getForecastData( + coordinates: GeoCoordinates, + pws: PWS | undefined, + currentDayEpoch: number + ): Promise { + + console.log(`[Hybrid-OpenMeteo] Fetching forecast data via WeatherData API`); + + // Get weather data which includes daily forecast + const weatherData = await this.cloudProvider.getWeatherDataInternal(coordinates, pws); + + // Convert forecast array to WateringData format + const allForecastData: WateringData[] = weatherData.forecast.map(day => ({ + weatherProvider: "OpenMeteo", + periodStartTime: day.date, + temp: (day.temp_min + day.temp_max) / 2, // Average temperature + minTemp: day.temp_min, + maxTemp: day.temp_max, + humidity: 50, // Default - OpenMeteo daily API doesn't provide hourly humidity averages + minHumidity: 40, // Reasonable defaults + maxHumidity: 60, + precip: day.precip, + solarRadiation: undefined, // Not available in daily API + windSpeed: undefined // Not available in daily API (could add windspeed_10m_max if needed) + })); + + console.log(`[Hybrid-OpenMeteo DEBUG] Converted ${allForecastData.length} forecast days to WateringData`); + console.log(`[Hybrid-OpenMeteo DEBUG] Raw forecast precip values:`); + weatherData.forecast.forEach((day, i) => { + const date = new Date(day.date * 1000).toISOString().split('T')[0]; + console.log(` ${i+1}. ${date}: precip=${day.precip}" (from OpenMeteo)`); + }); + + if (allForecastData.length > 0) { + console.log(`[Hybrid-OpenMeteo DEBUG] First entry periodStartTime: ${allForecastData[0].periodStartTime} (${new Date(allForecastData[0].periodStartTime * 1000).toISOString()})`); + console.log(`[Hybrid-OpenMeteo DEBUG] Last entry periodStartTime: ${allForecastData[allForecastData.length-1].periodStartTime} (${new Date(allForecastData[allForecastData.length-1].periodStartTime * 1000).toISOString()})`); + } + + console.log(`[Hybrid-OpenMeteo DEBUG] Current day epoch: ${currentDayEpoch} (${new Date(currentDayEpoch * 1000).toISOString()})`); + console.log(`[Hybrid-OpenMeteo DEBUG] Tomorrow epoch: ${currentDayEpoch + (24*60*60)} (${new Date((currentDayEpoch + 24*60*60) * 1000).toISOString()})`); + + // Filter to only keep FUTURE forecast data (starting tomorrow) + // We exclude today because we already have real measurements from local PWS + // This ensures today's data is always actual conditions, not predictions + const tomorrowEpoch = currentDayEpoch + (24 * 60 * 60); + const futureData = allForecastData.filter(data => + data.periodStartTime >= tomorrowEpoch + ); + + console.log(`[Hybrid-OpenMeteo DEBUG] After filtering (>= tomorrow): ${futureData.length} entries`); + + return futureData; + } +} \ No newline at end of file diff --git a/src/routes/weatherProviders/hybrid-providers/HybridPirateWeatherProvider.ts b/src/routes/weatherProviders/hybrid-providers/HybridPirateWeatherProvider.ts new file mode 100644 index 0000000..c450f32 --- /dev/null +++ b/src/routes/weatherProviders/hybrid-providers/HybridPirateWeatherProvider.ts @@ -0,0 +1,82 @@ +import { GeoCoordinates, WateringData, PWS } from "../../../types"; +import { WeatherProvider } from "../WeatherProvider"; +import { BaseHybridProvider } from "./BaseHybridProvider"; + +/** + * HybridPirateWeatherProvider - Combines local PWS data with PirateWeather forecasts. + * + * PirateWeather-specific implementation notes: + * - PirateWeather provides forecast data via getWeatherDataInternal() which includes a forecast array + * - PirateWeather is a free/open-source alternative to Dark Sky API + * - We convert the daily forecast data to WateringData format + * - PirateWeather daily API provides: temperatureMin, temperatureMax, precipIntensity (converted to daily total) + * - Missing data (humidity, solar, wind) gets reasonable defaults or undefined + * - NOTE: PirateWeather requires an API key (free tier available) + */ +export default class HybridPirateWeatherProvider extends BaseHybridProvider { + + constructor(cloudProvider: WeatherProvider) { + super(cloudProvider, "PirateWeather"); + } + + /** + * Get forecast data from PirateWeather and convert to WateringData format. + * + * PirateWeather returns forecast data through getWeatherDataInternal() in the forecast array. + * Each forecast day includes: time (Unix timestamp), temp_min, temp_max, precip (precipIntensity * 24) + * + * We filter to only include days AFTER today (tomorrow onwards) to avoid overlap + * with local historical data. + * + * @param coordinates Geographic coordinates + * @param pws PWS information (may contain API key) + * @param currentDayEpoch Unix timestamp for start of current day + * @returns Array of WateringData for future days + */ + protected async getForecastData( + coordinates: GeoCoordinates, + pws: PWS | undefined, + currentDayEpoch: number + ): Promise { + + console.log(`[Hybrid-PirateWeather] Fetching forecast data via WeatherData API`); + + // Get weather data which includes daily forecast + const weatherData = await this.cloudProvider.getWeatherDataInternal(coordinates, pws); + + // Convert forecast array to WateringData format + const allForecastData: WateringData[] = weatherData.forecast.map(day => ({ + weatherProvider: "PirateWeather", + periodStartTime: day.date, + temp: (day.temp_min + day.temp_max) / 2, // Average temperature + minTemp: day.temp_min, + maxTemp: day.temp_max, + humidity: 50, // Default - PirateWeather daily API doesn't provide hourly humidity averages + minHumidity: 40, // Reasonable defaults + maxHumidity: 60, + precip: day.precip, // Already converted (precipIntensity * 24) in PirateWeather provider + solarRadiation: undefined, // Not available in daily API + windSpeed: undefined // Not available in PirateWeather daily forecast API + })); + + console.log(`[Hybrid-PirateWeather DEBUG] Converted ${allForecastData.length} forecast days to WateringData`); + + if (allForecastData.length > 0) { + console.log(`[Hybrid-PirateWeather DEBUG] First entry periodStartTime: ${allForecastData[0].periodStartTime} (${new Date(allForecastData[0].periodStartTime * 1000).toISOString()})`); + console.log(`[Hybrid-PirateWeather DEBUG] Last entry periodStartTime: ${allForecastData[allForecastData.length-1].periodStartTime} (${new Date(allForecastData[allForecastData.length-1].periodStartTime * 1000).toISOString()})`); + } + + console.log(`[Hybrid-PirateWeather DEBUG] Current day epoch: ${currentDayEpoch} (${new Date(currentDayEpoch * 1000).toISOString()})`); + console.log(`[Hybrid-PirateWeather DEBUG] Tomorrow epoch: ${currentDayEpoch + (24*60*60)} (${new Date((currentDayEpoch + 24*60*60) * 1000).toISOString()})`); + + // Filter to only keep FUTURE forecast data (starting tomorrow) + const tomorrowEpoch = currentDayEpoch + (24 * 60 * 60); + const futureData = allForecastData.filter(data => + data.periodStartTime >= tomorrowEpoch + ); + + console.log(`[Hybrid-PirateWeather DEBUG] After filtering (>= tomorrow): ${futureData.length} entries`); + + return futureData; + } +} diff --git a/src/routes/weatherProviders/hybrid-providers/HybridWUndergroundProvider.ts b/src/routes/weatherProviders/hybrid-providers/HybridWUndergroundProvider.ts new file mode 100644 index 0000000..4cd55a0 --- /dev/null +++ b/src/routes/weatherProviders/hybrid-providers/HybridWUndergroundProvider.ts @@ -0,0 +1,83 @@ +import { GeoCoordinates, WateringData, PWS } from "../../../types"; +import { WeatherProvider } from "../WeatherProvider"; +import { BaseHybridProvider } from "./BaseHybridProvider"; + +/** + * HybridWUndergroundProvider - Combines local PWS data with Weather Underground forecasts. + * + * Weather Underground-specific implementation notes: + * - WU provides forecast data via getWeatherDataInternal() which includes a forecast array + * - We convert the daily forecast data to WateringData format + * - WU daily API provides: temperatureMin, temperatureMax, qpf (precip), qpfSnow + * - Missing data (humidity, solar, wind) gets reasonable defaults or undefined + * - NOTE: WU requires a PWS with apiKey for authentication + */ +export default class HybridWUndergroundProvider extends BaseHybridProvider { + + constructor(cloudProvider: WeatherProvider) { + super(cloudProvider, "WUnderground"); + } + + /** + * Get forecast data from Weather Underground and convert to WateringData format. + * + * WU returns forecast data through getWeatherDataInternal() in the forecast array. + * Each forecast day includes: date (validTimeUtc), temp_min, temp_max, precip (qpf + qpfSnow) + * + * We filter to only include days AFTER today (tomorrow onwards) to avoid overlap + * with local historical data. + * + * @param coordinates Geographic coordinates + * @param pws PWS information (REQUIRED by WU - must include id and apiKey) + * @param currentDayEpoch Unix timestamp for start of current day + * @returns Array of WateringData for future days + */ + protected async getForecastData( + coordinates: GeoCoordinates, + pws: PWS | undefined, + currentDayEpoch: number + ): Promise { + + console.log(`[Hybrid-WU] Fetching forecast data via WeatherData API`); + + // Get weather data which includes daily forecast + const weatherData = await this.cloudProvider.getWeatherDataInternal(coordinates, pws); + + // Convert forecast array to WateringData format + const allForecastData: WateringData[] = weatherData.forecast.map(day => ({ + weatherProvider: "WUnderground", + periodStartTime: day.date, + temp: (day.temp_min + day.temp_max) / 2, // Average temperature + minTemp: day.temp_min, + maxTemp: day.temp_max, + humidity: 50, // Default - WU daily API doesn't provide hourly humidity averages + minHumidity: 40, // Reasonable defaults + maxHumidity: 60, + precip: day.precip, // Already combined (qpf + qpfSnow) in WU provider + solarRadiation: undefined, // Not available in daily API + windSpeed: undefined // Not available in WU daily forecast API + })); + + console.log(`[Hybrid-WU DEBUG] Converted ${allForecastData.length} forecast days to WateringData`); + + if (allForecastData.length > 0) { + console.log(`[Hybrid-WU DEBUG] First entry periodStartTime: ${allForecastData[0].periodStartTime} (${new Date(allForecastData[0].periodStartTime * 1000).toISOString()})`); + console.log(`[Hybrid-WU DEBUG] Last entry periodStartTime: ${allForecastData[allForecastData.length-1].periodStartTime} (${new Date(allForecastData[allForecastData.length-1].periodStartTime * 1000).toISOString()})`); + } + + console.log(`[Hybrid-WU DEBUG] Current day epoch: ${currentDayEpoch} (${new Date(currentDayEpoch * 1000).toISOString()})`); + console.log(`[Hybrid-WU DEBUG] Tomorrow epoch: ${currentDayEpoch + (24*60*60)} (${new Date((currentDayEpoch + 24*60*60) * 1000).toISOString()})`); + + // Filter to only keep FUTURE forecast data (starting tomorrow) + // We exclude today because we already have real measurements from local PWS + // This ensures today's data is always actual conditions, not predictions + const tomorrowEpoch = currentDayEpoch + (24 * 60 * 60); + const futureData = allForecastData.filter(data => + data.periodStartTime >= tomorrowEpoch + ); + + console.log(`[Hybrid-WU DEBUG] After filtering (>= tomorrow): ${futureData.length} entries`); + + return futureData; + } +} diff --git a/src/routes/weatherProviders/hybrid-providers/index.ts b/src/routes/weatherProviders/hybrid-providers/index.ts new file mode 100644 index 0000000..c1ee150 --- /dev/null +++ b/src/routes/weatherProviders/hybrid-providers/index.ts @@ -0,0 +1,8 @@ +export { BaseHybridProvider } from "./BaseHybridProvider"; +export { default as HybridOpenMeteoProvider } from "./HybridOpenMeteoProvider"; +export { default as HybridAppleProvider } from "./HybridAppleProvider"; +export { default as HybridOWMProvider } from "./HybridOWMProvider"; +export { default as HybridWUndergroundProvider } from "./HybridWUndergroundProvider"; +export { default as HybridAccuWeatherProvider } from "./HybridAccuWeatherProvider"; +export { default as HybridDWDProvider } from "./HybridDWDProvider"; +export { default as HybridPirateWeatherProvider } from "./HybridPirateWeatherProvider"; diff --git a/src/routes/weatherProviders/hybrid.ts b/src/routes/weatherProviders/hybrid.ts new file mode 100644 index 0000000..a872c1a --- /dev/null +++ b/src/routes/weatherProviders/hybrid.ts @@ -0,0 +1,217 @@ +import { GeoCoordinates, WeatherData, WateringData, PWS } from "../../types"; +import { WeatherProvider } from "./WeatherProvider"; +import { + HybridOpenMeteoProvider, + HybridAppleProvider, + HybridOWMProvider, + HybridAccuWeatherProvider, + HybridDWDProvider, + HybridPirateWeatherProvider, + HybridWUndergroundProvider +} from "./hybrid-providers"; +import { CodedError, ErrorCode } from "../../errors"; +import { CachedResult } from "../../cache"; + +/** + * HybridWeatherProvider - Factory class that delegates to cloud-specific implementations. + * + * GOAL: Act EXACTLY like a standard provider (e.g. OpenMeteo), but with better data: + * - Past + Current: Local weather station (actual measurements) + * - Future: Cloud provider (professional forecasts) + * + * For Zimmerman, Weather Restrictions, and UI, this is TRANSPARENT. + * + * Supports all 7 providers: + * - OpenMeteo (free, no API key) + * - Apple + * - OWM (OpenWeatherMap) + * - AccuWeather + * - DWD/Bright Sky (Germany) + * - PirateWeather + * - Weather Underground + */ +export default class HybridWeatherProvider extends WeatherProvider { + private forecastProviders: Map; + private activeHybridProvider: WeatherProvider | null = null; + private activeProviderName: string | null = null; + + // Cache for combined watering data (historical + forecast) + private cachedCombinedData: readonly WateringData[] | null = null; + private cacheCoordinates: GeoCoordinates | null = null; + private cacheTimestamp: number = 0; + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + public constructor(forecastProviders: Map) { + super(); + this.forecastProviders = forecastProviders; + } + + /** + * Factory method: Creates the appropriate Hybrid provider for ANY cloud provider. + */ + private createHybridProvider(forecastProviderName: string): WeatherProvider { + const cloudProvider = this.forecastProviders.get(forecastProviderName); + + if (!cloudProvider) { + throw new CodedError(ErrorCode.InvalidProvider); + } + + switch (forecastProviderName) { + case 'OpenMeteo': + console.log(`[HybridFactory] Creating HybridOpenMeteoProvider`); + return new HybridOpenMeteoProvider(cloudProvider); + + case 'Apple': + console.log(`[HybridFactory] Creating HybridAppleProvider`); + return new HybridAppleProvider(cloudProvider); + + case 'OWM': + console.log(`[HybridFactory] Creating HybridOWMProvider`); + return new HybridOWMProvider(cloudProvider); + + case 'AccuWeather': + console.log(`[HybridFactory] Creating HybridAccuWeatherProvider`); + return new HybridAccuWeatherProvider(cloudProvider); + + case 'DWD': + console.log(`[HybridFactory] Creating HybridDWDProvider`); + return new HybridDWDProvider(cloudProvider); + + case 'PirateWeather': + console.log(`[HybridFactory] Creating HybridPirateWeatherProvider`); + return new HybridPirateWeatherProvider(cloudProvider); + + case 'WU': + console.log(`[HybridFactory] Creating HybridWUndergroundProvider`); + return new HybridWUndergroundProvider(cloudProvider); + + default: + console.error(`[HybridFactory] Unknown provider: ${forecastProviderName}`); + throw new CodedError(ErrorCode.InvalidProvider); + } + } + + /** + * Get watering data using the appropriate hybrid provider. + * Called from weather.ts BEFORE Zimmerman runs. + */ + public async getWateringDataWithForecastProvider( + coordinates: GeoCoordinates, + pws: PWS | undefined, + forecastProviderName: string + ): Promise { + + // Create or reuse the hybrid provider + if (this.activeProviderName !== forecastProviderName || !this.activeHybridProvider) { + console.log(`[HybridFactory] Switching to forecast provider: ${forecastProviderName}`); + this.activeHybridProvider = this.createHybridProvider(forecastProviderName); + this.activeProviderName = forecastProviderName; + } + + // Get combined data and CACHE it + const combinedData = await this.activeHybridProvider.getWateringDataInternal(coordinates, pws); + + this.cachedCombinedData = combinedData; + this.cacheCoordinates = coordinates; + this.cacheTimestamp = Date.now(); + + console.log(`[HybridFactory] Cached ${combinedData.length} days of combined watering data`); + + return combinedData; + } + + /** + * CRITICAL: Override getWeatherData() to return WeatherData with forecast[] array. + * + * This is what Weather Restrictions use to check future rain! + * We must act EXACTLY like standard OpenMeteo/Apple providers. + */ + async getWeatherData(coordinates: GeoCoordinates, pws?: PWS): Promise> { + console.log('[HybridFactory] getWeatherData() called (for Weather Restrictions)'); + + // Get current weather from local station + let currentWeather: WeatherData; + try { + currentWeather = await this.getWeatherDataInternal(coordinates, pws); + } catch (err) { + console.error('[HybridFactory] Failed to get current weather:', err); + throw err; + } + + // Convert cached WateringData to forecast[] array + if (this.cachedCombinedData && this.cachedCombinedData.length > 0) { + console.log(`[HybridFactory] Converting ${this.cachedCombinedData.length} WateringData entries to forecast[] array`); + + currentWeather.forecast = this.cachedCombinedData.map(wd => ({ + temp_min: wd.minTemp, + temp_max: wd.maxTemp, + precip: wd.precip, + date: wd.periodStartTime, + icon: "01d", // Default icon + description: "" // Not critical for restrictions + })); + + console.log(`[HybridFactory] Created forecast[] with ${currentWeather.forecast.length} days`); + console.log(`[HybridFactory] First forecast: ${new Date(currentWeather.forecast[0].date * 1000).toISOString().split('T')[0]}, precip=${currentWeather.forecast[0].precip}"`); + if (currentWeather.forecast.length > 1) { + console.log(`[HybridFactory] Second forecast: ${new Date(currentWeather.forecast[1].date * 1000).toISOString().split('T')[0]}, precip=${currentWeather.forecast[1].precip}"`); + } + } else { + console.warn('[HybridFactory] No cached data available for forecast[] array'); + currentWeather.forecast = []; + } + + return { + value: currentWeather, + ttl: Date.now() + this.CACHE_TTL + }; + } + + /** + * Get current weather from local station. + */ + protected async getWeatherDataInternal( + coordinates: GeoCoordinates, + pws: PWS | undefined + ): Promise { + if (!this.activeHybridProvider) { + const defaultProvider = 'Apple'; + console.log(`[HybridFactory] No active provider, defaulting to ${defaultProvider}`); + this.activeHybridProvider = this.createHybridProvider(defaultProvider); + this.activeProviderName = defaultProvider; + } + + return await this.activeHybridProvider.getWeatherDataInternal(coordinates, pws); + } + + /** + * CRITICAL: Override getWateringData() to return combined data to Zimmerman. + * + * This bypasses the base class cache and returns our cached combined data. + */ + getWateringData(coordinates: GeoCoordinates, pws?: PWS): Promise> { + console.log('[HybridFactory] getWateringData() called (for Zimmerman)'); + + const now = Date.now(); + const cacheValid = this.cachedCombinedData && + this.cacheCoordinates && + this.cacheCoordinates[0] === coordinates[0] && + this.cacheCoordinates[1] === coordinates[1] && + (now - this.cacheTimestamp) < this.CACHE_TTL; + + if (cacheValid && this.cachedCombinedData) { + console.log(`[HybridFactory] Returning cached ${this.cachedCombinedData.length} days to Zimmerman`); + return Promise.resolve({ + value: this.cachedCombinedData, + ttl: this.cacheTimestamp + this.CACHE_TTL + }); + } + + console.warn('[HybridFactory] No cached data, falling back to base class'); + return super.getWateringData(coordinates, pws); + } + + public shouldCacheWateringScale(): boolean { + return true; + } +} diff --git a/src/routes/weatherProviders/local.ts b/src/routes/weatherProviders/local.ts index af67f32..b652078 100644 --- a/src/routes/weatherProviders/local.ts +++ b/src/routes/weatherProviders/local.ts @@ -1,35 +1,88 @@ import express from "express"; import fs from "fs"; +import path from "path"; +import { startOfDay, subDays, getUnixTime } from "date-fns"; +import { localTime } from "../weather"; import { GeoCoordinates, WeatherData, WateringData, PWS } from "../../types"; import { WeatherProvider } from "./WeatherProvider"; import { CodedError, ErrorCode } from "../../errors"; import { getParameter } from "../weather"; +// Interface MUSS vor der Verwendung definiert werden +interface Observation { + timestamp: number; + temp: number | null; + humidity: number | null; + windSpeed: number | null; + solarRadiation: number | null; // Use null instead of undefined for JSON serialization + precip: number | null; +} + var queue: Array = [], lastRainEpoch = 0, - lastRainCount: number; + lastRainCount = 0; // Initialize to 0 instead of undefined + +// Export queue for debugging purposes +export function getQueue(): Array { + return queue; +} -function getMeasurement(req: express.Request, key: string): number { +const LOCAL_OBSERVATION_DAYS = 7; + +// Data Retention Strategy: +// - In-memory queue: Keep up to 8 days (LOCAL_OBSERVATION_DAYS + 1) +// - getWeatherDataInternal: Uses last 24 hours (for current weather display) +// - getWateringDataInternal: Uses up to 7 complete days + today (for Zimmerman calculation) +// - saveQueue: Trims to 8 days and persists every 30 minutes + +// Configure data directory from environment variable or use default +// Using PERSISTENCE_LOCATION as per PR #144, but with path.join() for cross-platform compatibility (Copilot suggestion) +const dataDir = process.env.PERSISTENCE_LOCATION || path.join(__dirname, '..', '..', 'data'); +const observationsPath = path.join(dataDir, 'observations.json'); + +function getMeasurement(req: express.Request, key: string): number | null { let value: number; - return ( key in req.query ) && !isNaN( value = parseFloat( getParameter(req.query[key]) ) ) && ( value !== -9999.0 ) ? value : undefined; + return ( key in req.query ) && !isNaN( value = parseFloat( getParameter(req.query[key]) ) ) && ( value !== -9999.0 ) ? value : null; } export const captureWUStream = async function( req: express.Request, res: express.Response ) { - let rainCount = getMeasurement(req, "dailyrainin"); + const rainCount = getMeasurement(req, "dailyrainin"); + const temp = getMeasurement(req, "tempf"); + const humidity = getMeasurement(req, "humidity"); + const windSpeed = getMeasurement(req, "windspeedmph"); + const solarRadiation = getMeasurement(req, "solarradiation"); + const rainin = getMeasurement(req, "rainin"); + + // Calculate precipitation safely + let precip: number | null = null; // Default to null instead of undefined + if (typeof rainCount === "number" && typeof lastRainCount === "number") { + // Handle rain counter reset (when new value is less than previous) + precip = rainCount < lastRainCount ? rainCount : rainCount - lastRainCount; + } else if (typeof rainCount === "number") { + // First reading or lastRainCount was invalid + precip = rainCount; + } const obs: Observation = { timestamp: req.query.dateutc === "now" ? Math.floor(Date.now()/1000) : Math.floor(new Date(String(req.query.dateutc) + "Z").getTime()/1000), - temp: getMeasurement(req, "tempf"), - humidity: getMeasurement(req, "humidity"), - windSpeed: getMeasurement(req, "windspeedmph"), - solarRadiation: getMeasurement(req, "solarradiation") * 24 / 1000, // Convert to kWh/m^2 per day - precip: rainCount < lastRainCount ? rainCount : rainCount - lastRainCount, + temp: temp, + humidity: humidity, + windSpeed: windSpeed, + solarRadiation: typeof solarRadiation === "number" ? solarRadiation * 24 / 1000 : null, // Use null instead of undefined for JSON serialization + precip: precip, }; - lastRainEpoch = getMeasurement(req, "rainin") > 0 ? obs.timestamp : lastRainEpoch; - lastRainCount = isNaN(rainCount) ? lastRainCount : rainCount; + // Update lastRainEpoch only if rainin is a valid number > 0 + if (typeof rainin === "number" && rainin > 0) { + lastRainEpoch = obs.timestamp; + } + + // Update lastRainCount only if rainCount is a valid number + if (typeof rainCount === "number") { + lastRainCount = rainCount; + } queue.unshift(obs); @@ -39,22 +92,27 @@ export const captureWUStream = async function( req: express.Request, res: expres export default class LocalWeatherProvider extends WeatherProvider { protected async getWeatherDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WeatherData > { - queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); + // IMPORTANT: Filter locally, don't modify global queue! + // getWateringDataInternal needs up to 7 days of data + const recentQueue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); - if ( queue.length == 0 ) { + if ( recentQueue.length == 0 ) { console.error( "There is insufficient data to support Weather response from local PWS." ); throw "There is insufficient data to support Weather response from local PWS."; } + // Get most recent observation + const latest = recentQueue[0]; + const weather: WeatherData = { weatherProvider: "local", - temp: Math.floor( queue[ 0 ].temp ) || undefined, + temp: typeof latest.temp === "number" ? Math.floor(latest.temp) : undefined, minTemp: undefined, maxTemp: undefined, - humidity: Math.floor( queue[ 0 ].humidity ) || undefined , - wind: Math.floor( queue[ 0 ].windSpeed * 10 ) / 10 || undefined, + humidity: typeof latest.humidity === "number" ? Math.floor(latest.humidity) : undefined, + wind: typeof latest.windSpeed === "number" ? Math.floor(latest.windSpeed * 10) / 10 : undefined, raining: false, - precip: Math.floor( queue.reduce( ( sum, obs ) => sum + ( obs.precip || 0 ), 0) * 100 ) / 100, + precip: Math.floor( recentQueue.reduce( ( sum, obs ) => sum + ( typeof obs.precip === "number" ? obs.precip : 0 ), 0) * 100 ) / 100, description: "", icon: "01d", region: undefined, @@ -70,68 +128,159 @@ export default class LocalWeatherProvider extends WeatherProvider { } protected async getWateringDataInternal( coordinates: GeoCoordinates, pws: PWS | undefined ): Promise< WateringData[] > { + // Note: Queue trimming is handled by saveQueue() which runs every 30 minutes + // DO NOT trim the global queue here as it causes data loss! - queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); - - if ( queue.length == 0 || queue[ 0 ].timestamp - queue[ queue.length - 1 ].timestamp < 23*60*60 ) { + if ( queue.length == 0 || queue[0].timestamp - queue[queue.length-1].timestamp < 23*60*60) { console.error( "There is insufficient data to support watering calculation from local PWS." ); throw new CodedError( ErrorCode.InsufficientWeatherData ); } - let cTemp = 0, cHumidity = 0, cPrecip = 0, cSolar = 0, cWind = 0; - const result: WateringData = { - weatherProvider: "local", - temp: queue.reduce( ( sum, obs ) => !isNaN( obs.temp ) && ++cTemp ? sum + obs.temp : sum, 0) / cTemp, - humidity: queue.reduce( ( sum, obs ) => !isNaN( obs.humidity ) && ++cHumidity ? sum + obs.humidity : sum, 0) / cHumidity, - precip: queue.reduce( ( sum, obs ) => !isNaN( obs.precip ) && ++cPrecip ? sum + obs.precip : sum, 0), - periodStartTime: Math.floor( queue[ queue.length - 1 ].timestamp ), - minTemp: queue.reduce( (min, obs) => ( min > obs.temp ) ? obs.temp : min, Infinity ), - maxTemp: queue.reduce( (max, obs) => ( max < obs.temp ) ? obs.temp : max, -Infinity ), - minHumidity: queue.reduce( (min, obs) => ( min > obs.humidity ) ? obs.humidity : min, Infinity ), - maxHumidity: queue.reduce( (max, obs) => ( max < obs.humidity ) ? obs.humidity : max, -Infinity ), - solarRadiation: queue.reduce( (sum, obs) => !isNaN( obs.solarRadiation ) && ++cSolar ? sum + obs.solarRadiation : sum, 0) / cSolar, - windSpeed: queue.reduce( (sum, obs) => !isNaN( obs.windSpeed ) && ++cWind ? sum + obs.windSpeed : sum, 0) / cWind - }; + // 2. Determine day boundaries + const currentDay = startOfDay(localTime(coordinates)); // today 00:00 local + const now = Math.floor(Date.now() / 1000); // current time in epoch + const startTime = getUnixTime(subDays(currentDay, 7)); // 7 days ago at midnight - if ( !( cTemp && cHumidity && cPrecip ) || - [ result.minTemp, result.minHumidity, -result.maxTemp, -result.maxHumidity ].includes( Infinity ) || - !( cSolar && cWind && cPrecip )) { - console.error( "There is insufficient data to support watering calculation from local PWS." ); - throw new CodedError( ErrorCode.InsufficientWeatherData ); + // Filter to include data from 7 days ago up to NOW (including today's partial data) + // This gives hybrid mode the most accurate recent data from the local station + const filteredData = queue.filter(obs => obs.timestamp >= startTime && obs.timestamp <= now); + const data: WateringData[] = []; + + // 3. Loop over each day from TODAY back to 7 days ago + // Start with today (partial day with data up to now) + let dayEnd = new Date(now * 1000); // Current time + let dayStart = currentDay; // Today at midnight + + // First iteration: Today (partial day) + let dayObs = filteredData.filter(obs => + obs.timestamp >= getUnixTime(dayStart) && obs.timestamp <= now + ); + + if (dayObs.length > 0) { + // Process today's partial data + let cTemp=0, cHumidity=0, cPrecip=0, cSolar=0, cWind=0; + const avgTemp = dayObs.reduce((sum, obs) => typeof obs.temp === "number" && ++cTemp ? sum + obs.temp : sum, 0) / cTemp; + const avgHum = dayObs.reduce((sum, obs) => typeof obs.humidity === "number" && ++cHumidity ? sum + obs.humidity : sum, 0) / cHumidity; + const totalPrecip = dayObs.reduce((sum, obs) => typeof obs.precip === "number" && ++cPrecip ? sum + obs.precip : sum, 0); + const minTemp = dayObs.reduce((min, obs) => (typeof obs.temp === "number" && min > obs.temp ? obs.temp : min), Infinity); + const maxTemp = dayObs.reduce((max, obs) => (typeof obs.temp === "number" && max < obs.temp ? obs.temp : max), -Infinity); + const minHum = dayObs.reduce((min, obs) => (typeof obs.humidity === "number" && min > obs.humidity ? obs.humidity : min), Infinity); + const maxHum = dayObs.reduce((max, obs) => (typeof obs.humidity === "number" && max < obs.humidity ? obs.humidity : max), -Infinity); + const avgSolar= dayObs.reduce((sum, obs) => typeof obs.solarRadiation === "number" && ++cSolar ? sum + obs.solarRadiation : sum, 0) / cSolar; + const avgWind = dayObs.reduce((sum, obs) => typeof obs.windSpeed === "number" && ++cWind ? sum + obs.windSpeed : sum, 0) / cWind; + + if (cTemp && cHumidity && ![minTemp, minHum, -maxTemp, -maxHum].includes(Infinity) && cWind) { + // Note: solarRadiation is optional - not all weather stations have solar sensors + data.push({ + weatherProvider: "local", + periodStartTime: Math.floor(getUnixTime(dayStart)), + temp: avgTemp, + humidity: avgHum, + precip: totalPrecip, + minTemp: minTemp, + maxTemp: maxTemp, + minHumidity: minHum, + maxHumidity: maxHum, + solarRadiation: cSolar > 0 ? avgSolar : undefined, // Optional - may be null + windSpeed: avgWind + }); + } } - return [result]; - }; + // Continue with previous complete days (yesterday through 7 days ago) + dayEnd = currentDay; + for (let i = 0; i < 7; i++) { + let dayStart = subDays(dayEnd, 1); + + // Collect observations for this day [dayStart, dayEnd) + const dayObs = filteredData.filter(obs => + obs.timestamp >= getUnixTime(dayStart) && obs.timestamp < getUnixTime(dayEnd) + ); + + if (dayObs.length === 0) { + // No data for older days - stop here, return what we have + break; + } + + // 4. Calculate daily averages/totals + let cTemp=0, cHumidity=0, cPrecip=0, cSolar=0, cWind=0; + const avgTemp = dayObs.reduce((sum, obs) => typeof obs.temp === "number" && ++cTemp ? sum + obs.temp : sum, 0) / cTemp; + const avgHum = dayObs.reduce((sum, obs) => typeof obs.humidity === "number" && ++cHumidity ? sum + obs.humidity : sum, 0) / cHumidity; + const totalPrecip = dayObs.reduce((sum, obs) => typeof obs.precip === "number" && ++cPrecip ? sum + obs.precip : sum, 0); + const minTemp = dayObs.reduce((min, obs) => (typeof obs.temp === "number" && min > obs.temp ? obs.temp : min), Infinity); + const maxTemp = dayObs.reduce((max, obs) => (typeof obs.temp === "number" && max < obs.temp ? obs.temp : max), -Infinity); + const minHum = dayObs.reduce((min, obs) => (typeof obs.humidity === "number" && min > obs.humidity ? obs.humidity : min), Infinity); + const maxHum = dayObs.reduce((max, obs) => (typeof obs.humidity === "number" && max < obs.humidity ? obs.humidity : max), -Infinity); + const avgSolar= dayObs.reduce((sum, obs) => typeof obs.solarRadiation === "number" && ++cSolar ? sum + obs.solarRadiation : sum, 0) / cSolar; + const avgWind = dayObs.reduce((sum, obs) => typeof obs.windSpeed === "number" && ++cWind ? sum + obs.windSpeed : sum, 0) / cWind; + + // 5. Verify all required metrics are present + // Note: solarRadiation is optional - not all weather stations have solar sensors + if (!(cTemp && cHumidity) + || [minTemp, minHum, -maxTemp, -maxHum].includes(Infinity) + || !cWind) { + // Missing required data for older days - stop here + break; + } + + // 6. Create WateringData for this day + data.push({ + weatherProvider: "local", + periodStartTime: Math.floor(getUnixTime(dayStart)), // start of the day (epoch) + temp: avgTemp, + humidity: avgHum, + precip: totalPrecip, + minTemp: minTemp, + maxTemp: maxTemp, + minHumidity: minHum, + maxHumidity: maxHum, + solarRadiation: cSolar > 0 ? avgSolar : undefined, // Optional - may be null + windSpeed: avgWind + }); + + dayEnd = dayStart; // move to previous day + } + console.log(`[LocalWeather] Returning ${data.length} days of historical data`); + return data; + } } function saveQueue() { - queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); + const beforeCount = queue.length; + // Keep observations up to 8 days old (7 days for watering + 1 day buffer) + queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < (LOCAL_OBSERVATION_DAYS+1)*24*60*60 ); + const afterCount = queue.length; + const deletedCount = beforeCount - afterCount; + try { - fs.writeFileSync( "observations.json" , JSON.stringify( queue ), "utf8" ); + // Ensure data directory exists + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + fs.writeFileSync( observationsPath , JSON.stringify( queue ), "utf8" ); + + if (deletedCount > 0) { + console.log(`[LocalWeather] Trimmed ${deletedCount} observations older than ${LOCAL_OBSERVATION_DAYS+1} days. Kept ${afterCount} observations.`); + } } catch ( err ) { console.error( "Error saving historical observations to local storage.", err ); } } -if ( process.env.WEATHER_PROVIDER === "local" && process.env.LOCAL_PERSISTENCE ) { - if ( fs.existsSync( "observations.json" ) ) { +if ( process.env.LOCAL_PERSISTENCE ) { + // Load persisted observations on startup (works for both 'local' and 'hybrid' providers) + if ( fs.existsSync( observationsPath ) ) { try { - queue = JSON.parse( fs.readFileSync( "observations.json", "utf8" ) ); - queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < 24*60*60 ); + queue = JSON.parse( fs.readFileSync( observationsPath, "utf8" ) ); + queue = queue.filter( obs => Math.floor(Date.now()/1000) - obs.timestamp < (LOCAL_OBSERVATION_DAYS+1)*24*60*60 ); + console.log(`[LocalWeather] Loaded ${queue.length} persisted observations from ${observationsPath}`); } catch ( err ) { console.error( "Error reading historical observations from local storage.", err ); queue = []; } } + // Save observations every 30 minutes setInterval( saveQueue, 1000 * 60 * 30 ); -} - -interface Observation { - timestamp: number; - temp: number; - humidity: number; - windSpeed: number; - solarRadiation: number; - precip: number; -} + console.log(`[LocalWeather] Persistence enabled, saving to ${observationsPath} every 30 minutes`); +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index cdb0f48..92238a2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,31 +5,31 @@ import express from "express"; import cors from "cors"; import { getWateringData, getWeatherData } from "./routes/weather"; -import { captureWUStream } from "./routes/weatherProviders/local"; +import { captureWUStream, getQueue } from "./routes/weatherProviders/local"; import { getBaselineETo } from "./routes/baselineETo"; import {default as packageJson} from "../package.json"; import { pinoHttp } from "pino-http"; import { pino, LevelWithSilent } from "pino"; function getLogLevel(): LevelWithSilent { - switch (process.env.LOG_LEVEL) { - case "trace": - return "trace"; - case "debug": - return "debug"; - case "info": - return "info"; - case "warn": - return "warn"; - case "error": - return "error"; - case "fatal": - return "fatal"; - case "silent": - return "silent"; - default: - return "info"; - } +switch (process.env.LOG_LEVEL) { + case "trace": + return "trace"; + case "debug": + return "debug"; + case "info": + return "info"; + case "warn": + return "warn"; + case "error": + return "error"; + case "fatal": + return "fatal"; + case "silent": + return "silent"; + default: + return "info"; +} } const logger = pino({ level: getLogLevel() }); @@ -66,6 +66,19 @@ app.get( "/", function( req, res ) { app.options( /baselineETo/, cors() ); app.get( /baselineETo/, cors(), getBaselineETo ); +// Debug endpoint to inspect observation queue (useful for development) +app.get( "/debug/queue", function( req, res ) { + const queue = getQueue(); + const count = parseInt(req.query.count as string) || 10; + const recentObs = queue.slice(-count); // Get last N observations + + res.json({ + totalCount: queue.length, + showing: recentObs.length, + observations: recentObs + }); +}); + // Handle 404 error app.use( function( req, res ) { res.status( 404 ); @@ -79,4 +92,4 @@ app.listen( port, host, function() { if (pws !== "none" ) { console.log( "%s now listening for local weather stream", packageJson.description ); } -} ); +} ); \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 129f3cb..3555b90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -90,5 +90,5 @@ export interface WateringData { windSpeed: number; } -export type WeatherProviderId = "OWM" | "PirateWeather" | "local" | "mock" | "WUnderground" | "DWD" | "OpenMeteo" | "AccuWeather" | "Apple"; -export type WeatherProviderShortId = "OWM" | "PW" | "local" | "mock" | "WU" | "DWD" | "OpenMeteo" | "AW" | "Apple"; +export type WeatherProviderId = "OWM" | "PirateWeather" | "local" | "hybrid" | "mock" | "WUnderground" | "DWD" | "OpenMeteo" | "AccuWeather" | "Apple" | `local+${string}`; +export type WeatherProviderShortId = "OWM" | "PW" | "local" | "hybrid" | "mock" | "WU" | "DWD" | "OpenMeteo" | "AW" | "Apple"; \ No newline at end of file