// api/session-stats.prg — Compute session statistics (FLOAT MATH IN PRG) // // ctx: // session_id — session ID // duration_sec — duration in seconds (from server.js) // record_count — total records // rows — JSON array of records [{sensor: {tempC, batteryMv, ...}, channels: [...]}] // // PRG handles all numeric aggregation: mean, min, max, stddev for 6 channels. // Float computation is done here (Harbour double precision) — easier maintenance // than maintaining SQL JSONB aggregates in server.js. // // Copyright (c) 2026 Charles KWON OhJun FUNCTION Main() LOCAL aRows, hRow, nDuration, nCount, hSensor, aChannels, hCh LOCAL nTempSum, nTempMin, nTempMax, nTempCnt LOCAL nBatStart, nBatEnd LOCAL aChStats, i, ch, peakSums, peakMins, peakMaxs, peakCounts, peakSqs LOCAL nMean, nVar, nStdDev, hOutCh, aChOut LOCAL cDur, nH, nM, nS aRows := hb_jsonDecode(ctx_get("rows", "[]")) nDuration := Val(ctx_get("duration_sec", "0")) nCount := Val(ctx_get("record_count", "0")) IF ! HB_ISARRAY(aRows) ; aRows := {} ; ENDIF // ─ Init accumulators ── nTempSum := 0 nTempMin := 999.9 nTempMax := -999.9 nTempCnt := 0 nBatStart := NIL nBatEnd := NIL // 6 channels: peak sum / min / max / count / sum-of-squares (for stddev) peakSums := { 0, 0, 0, 0, 0, 0 } peakMins := { 99999, 99999, 99999, 99999, 99999, 99999 } peakMaxs := { -99999, -99999, -99999, -99999, -99999, -99999 } peakCounts := { 0, 0, 0, 0, 0, 0 } peakSqs := { 0, 0, 0, 0, 0, 0 } // ─ Iterate records ── FOR EACH hRow IN aRows // sensor hSensor := fn_HGet(hRow, "sensor") IF HB_ISHASH(hSensor) IF hb_HHasKey(hSensor, "tempC") .AND. ValType(hSensor["tempC"]) == "N" nTempSum += hSensor["tempC"] nTempCnt++ IF hSensor["tempC"] < nTempMin ; nTempMin := hSensor["tempC"] ; ENDIF IF hSensor["tempC"] > nTempMax ; nTempMax := hSensor["tempC"] ; ENDIF ENDIF IF hb_HHasKey(hSensor, "batteryMv") .AND. ValType(hSensor["batteryMv"]) == "N" IF nBatStart == NIL ; nBatStart := hSensor["batteryMv"] ; ENDIF nBatEnd := hSensor["batteryMv"] ENDIF ENDIF // channels aChannels := fn_HGet(hRow, "channels") IF HB_ISARRAY(aChannels) FOR EACH hCh IN aChannels IF HB_ISHASH(hCh) .AND. hb_HHasKey(hCh, "ch") .AND. hb_HHasKey(hCh, "peak") i := hCh["ch"] + 1 // 1-based IF i >= 1 .AND. i <= 6 .AND. ValType(hCh["peak"]) == "N" peakSums[i] += hCh["peak"] peakSqs[i] += hCh["peak"] * hCh["peak"] peakCounts[i]++ IF hCh["peak"] < peakMins[i] ; peakMins[i] := hCh["peak"] ; ENDIF IF hCh["peak"] > peakMaxs[i] ; peakMaxs[i] := hCh["peak"] ; ENDIF ENDIF ENDIF NEXT ENDIF NEXT // ─ Compute per-channel stats (mean, stddev) ── aChOut := {} FOR i := 1 TO 6 ch := i - 1 IF peakCounts[i] > 0 nMean := peakSums[i] / peakCounts[i] // population variance: E[x^2] - (E[x])^2 nVar := (peakSqs[i] / peakCounts[i]) - (nMean * nMean) IF nVar < 0 ; nVar := 0 ; ENDIF nStdDev := Sqrt(nVar) hOutCh := { ; "ch" => ch, ; "peakAvg" => Round(nMean, 2), ; "peakMin" => peakMins[i], ; "peakMax" => peakMaxs[i], ; "peakStdDev" => Round(nStdDev, 2), ; "samples" => peakCounts[i] ; } ELSE hOutCh := { "ch" => ch, "peakAvg" => NIL, "peakMin" => NIL, "peakMax" => NIL, "peakStdDev" => NIL, "samples" => 0 } ENDIF AAdd(aChOut, hOutCh) NEXT // ─ Format duration HH:MM:SS ── nH := Int(nDuration / 3600) nM := Int((nDuration % 3600) / 60) nS := Int(nDuration % 60) cDur := iif(nDuration > 0, ; StrZero(nH, 2) + ":" + StrZero(nM, 2) + ":" + StrZero(nS, 2), ; "") AP_JSONRESPONSE({ ; "sessionId" => ctx_get("session_id", ""), ; "recordCount" => nCount, ; "duration" => cDur, ; "channels" => aChOut, ; "temperature" => { ; "avg" => iif(nTempCnt > 0, Round(nTempSum / nTempCnt, 2), NIL), ; "min" => iif(nTempCnt > 0, nTempMin, NIL), ; "max" => iif(nTempCnt > 0, nTempMax, NIL) ; }, ; "battery" => { ; "startMv" => nBatStart, ; "endMv" => nBatEnd, ; "drainMv" => iif(nBatStart != NIL .AND. nBatEnd != NIL, nBatStart - nBatEnd, NIL), ; "drainPctPerHour" => iif(nBatStart != NIL .AND. nBatEnd != NIL .AND. nDuration > 0, ; Round(((nBatStart - nBatEnd) / nBatStart) * 3600 / nDuration * 100, 2), NIL) ; } ; }) RETURN NIL STATIC FUNCTION fn_HGet(h, k) IF HB_ISHASH(h) .AND. hb_HHasKey(h, k) ; RETURN h[k] ; ENDIF RETURN NIL