@@ -703,13 +703,29 @@ app.get("/api/export", async (c) => {
703703// One place both the app (/api/totals) and the cron's day-ahead log read from, so
704704// what gets evaluated is exactly what was shown. Memoised per isolate 30 min.
705705let memModel : { at : number ; m : { capKw : number ; calibDays : number ; sky : Record < number , number > ; skyDays : number } } | null = null ;
706+ // 06:00–17:59 = 144 five-minute samples; require 80 % of them
707+ const DAYLIGHT_COVER_MIN = 115 ;
706708async function evaluatedForecastRows ( env : Env , days : number ) {
707709 const today = bkkDayOf ( Date . now ( ) ) ;
708710 const cutoff = bkkDayOf ( Date . now ( ) - days * 86400000 ) ;
711+ // A day only counts as "actual" when our own 5-min samples cover most of its daylight: a
712+ // logger outage leaves Deye's day total partial (e.g. 2.1 kWh for a ~30 kWh day), and judging
713+ // the forecast — or teaching the sky model — on that would be wrong. Such days come back with
714+ // actual = null and incomplete = true.
715+ const since = Math . floor ( Date . parse ( cutoff + "T00:00:00+07:00" ) / 1000 ) ;
709716 const { results } = await env . DB . prepare (
710- `SELECT f.day, f.predicted, f.cond, f.psh, f.cap_kw, d.gen AS actual FROM forecast_log f LEFT JOIN daily d ON d.day = f.day
711- WHERE f.day >= ? AND f.day < ? ORDER BY f.day` ) . bind ( cutoff , today ) . all ( ) ;
712- return ( results || [ ] ) as { day : string ; predicted : number ; cond : number ; psh : number ; cap_kw : number ; actual : number | null } [ ] ;
717+ `SELECT f.day, f.predicted, f.cond, f.psh, f.cap_kw, d.gen AS actual, COALESCE(c.n, 0) AS cover
718+ FROM forecast_log f
719+ LEFT JOIN daily d ON d.day = f.day
720+ LEFT JOIN (SELECT date(ts + 25200, 'unixepoch') AS day, COUNT(*) AS n FROM samples
721+ WHERE ts >= ? AND CAST(strftime('%H', ts + 25200, 'unixepoch') AS INTEGER) BETWEEN 6 AND 17
722+ GROUP BY 1) c ON c.day = f.day
723+ WHERE f.day >= ? AND f.day < ? ORDER BY f.day` ) . bind ( since , cutoff , today ) . all ( ) ;
724+ return ( ( results || [ ] ) as any [ ] ) . map ( ( r ) => {
725+ const complete = Number ( r . cover ) >= DAYLIGHT_COVER_MIN ;
726+ return { day : String ( r . day ) , predicted : Number ( r . predicted ) , cond : Number ( r . cond ) , psh : Number ( r . psh ) , cap_kw : Number ( r . cap_kw ) ,
727+ actual : complete && r . actual != null ? Number ( r . actual ) : null , incomplete : ! complete && r . actual != null } ;
728+ } ) ;
713729}
714730async function forecastModel ( env : Env ) {
715731 if ( memModel && Date . now ( ) - memModel . at < 30 * 60 * 1000 ) return memModel . m ;
@@ -802,7 +818,7 @@ app.get("/api/forecast/accuracy", async (c) => {
802818 const days = Math . min ( 365 , Math . max ( 7 , Number ( c . req . query ( "days" ) ) || 30 ) ) ;
803819 const rows = await evaluatedForecastRows ( c . env , days ) ;
804820 const s = accuracySummary ( rows . filter ( ( r ) => r . actual != null ) . map ( ( r ) => ( { day : r . day , predicted : Number ( r . predicted ) , actual : Number ( r . actual ) } ) ) ) ;
805- return c . json ( { days, ...s , rows : rows . map ( ( r ) => ( { day : r . day , predicted : Number ( r . predicted ) , actual : r . actual == null ? null : Number ( r . actual ) , cond : Number ( r . cond ) } ) ) } ) ;
821+ return c . json ( { days, ...s , rows : rows . map ( ( r ) => ( { day : r . day , predicted : Number ( r . predicted ) , actual : r . actual == null ? null : Number ( r . actual ) , cond : Number ( r . cond ) , incomplete : r . incomplete } ) ) } ) ;
806822} ) ;
807823
808824app . get ( "/api/weather" , async ( c ) => c . json ( await getWeather ( c . env ) ) ) ;
0 commit comments