Skip to content

Commit 8d335a9

Browse files
committed
feat: Read last value on start up, ignore decreases
`ocr-incr-only` and `ocr-max-incr` flags are meant to protect against faulty OCR, but they can't help if the very first reading after start up is faulty. Protect against that scenario by reading the last valid value from the CSV file during start up.
1 parent c3bc0bd commit 8d335a9

4 files changed

Lines changed: 178 additions & 7 deletions

File tree

http.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,14 @@ func processOCR(imageData []byte, batLevel, batVoltage int) {
114114

115115
reading := extractReading(ocrOut.Texts, ocrMatchRe, ocrFixRules, ocrMergeTexts)
116116

117-
// Append reading to CSV unconditionally so discarded values are still on disk.
118-
storeReading(imagePath, reading)
119-
120117
if reading == "" {
121118
log.Printf("OCR completed in %s: no reading found, texts=%v", elapsed, ocrOut.Texts)
122119
return
123120
}
124121

125122
val, err := strconv.ParseFloat(reading, 64)
126123
if err != nil {
124+
storeReading(imagePath, reading, false)
127125
log.Printf("OCR completed in %s: invalid reading %q, texts=%v", elapsed, reading, ocrOut.Texts)
128126
return
129127
}
@@ -135,13 +133,15 @@ func processOCR(imageData []byte, batLevel, batVoltage int) {
135133
prev := lastReading
136134
if reason := checkReadingFilter(divided, prev, ocrIncrOnly, ocrMaxIncr); reason != "" {
137135
lastReadingMu.Unlock()
136+
storeReading(imagePath, reading, false)
138137
log.Printf("%s", reason)
139138
return
140139
}
141140
lastReading = divided
142141
lastReadingMu.Unlock()
143142
}
144143

144+
storeReading(imagePath, reading, true)
145145
metricMeterReading.Set(val)
146146

147147
if mqttBroker != "" {

main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"log"
66
"net/http"
77
"os"
8+
"path/filepath"
89
"regexp"
910

1011
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -180,6 +181,14 @@ func run(_ context.Context, cmd *cli.Command) error {
180181
ocrMaxIncr = cmd.Float("ocr-max-incr")
181182
ocrMergeTexts = cmd.Bool("ocr-merge-texts")
182183

184+
if storagePath != "" && (ocrIncrOnly || ocrMaxIncr > 0) {
185+
csvPath := filepath.Join(storagePath, "readings.csv")
186+
if prev, ok := lastStoredReading(csvPath, meterDivisor); ok {
187+
lastReading = prev
188+
log.Printf("seeded last reading from CSV: %.3f m³", prev)
189+
}
190+
}
191+
183192
if mqttBroker != "" {
184193
initMQTT()
185194
}

storage.go

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import (
55
"log"
66
"os"
77
"path/filepath"
8+
"strconv"
89
"sync"
910
"time"
1011
)
1112

1213
var csvMu sync.Mutex
1314

14-
func appendCSV(csvPath, imagePath, reading string) error {
15+
func appendCSV(csvPath, imagePath, reading string, valid bool) error {
1516
csvMu.Lock()
1617
defer csvMu.Unlock()
1718

@@ -21,8 +22,13 @@ func appendCSV(csvPath, imagePath, reading string) error {
2122
}
2223
defer f.Close()
2324

25+
validStr := "0"
26+
if valid {
27+
validStr = "1"
28+
}
29+
2430
w := csv.NewWriter(f)
25-
if err := w.Write([]string{imagePath, reading, time.Now().UTC().Format(time.RFC3339)}); err != nil {
31+
if err := w.Write([]string{imagePath, reading, time.Now().UTC().Format(time.RFC3339), validStr}); err != nil {
2632
return err
2733
}
2834
w.Flush()
@@ -77,13 +83,52 @@ func storeImages(imageData, processedData []byte, cropped, masked bool) string {
7783
}
7884

7985
// storeReading appends a row to readings.csv.
80-
func storeReading(imagePath, reading string) {
86+
func storeReading(imagePath, reading string, valid bool) {
8187
if storagePath == "" || imagePath == "" || reading == "" {
8288
return
8389
}
8490

8591
csvPath := filepath.Join(storagePath, "readings.csv")
86-
if err := appendCSV(csvPath, imagePath, reading); err != nil {
92+
if err := appendCSV(csvPath, imagePath, reading, valid); err != nil {
8793
log.Printf("csv append error: %v", err)
8894
}
95+
}
96+
97+
// lastStoredReading reads the last valid reading from a readings.csv file.
98+
// For backwards compatibility, rows without a valid column (old 3-column format)
99+
// are assumed valid.
100+
func lastStoredReading(csvPath string, divisor float64) (float64, bool) {
101+
f, err := os.Open(csvPath)
102+
if err != nil {
103+
return 0, false
104+
}
105+
defer f.Close()
106+
107+
r := csv.NewReader(f)
108+
r.FieldsPerRecord = -1
109+
110+
var lastVal float64
111+
var found bool
112+
113+
for {
114+
row, err := r.Read()
115+
if err != nil {
116+
break
117+
}
118+
if len(row) < 2 {
119+
continue
120+
}
121+
// Column 3 (index 3) is the valid flag. If absent (old format), assume valid.
122+
if len(row) >= 4 && row[3] == "0" {
123+
continue
124+
}
125+
val, err := strconv.ParseFloat(row[1], 64)
126+
if err != nil {
127+
continue
128+
}
129+
lastVal = val / divisor
130+
found = true
131+
}
132+
133+
return lastVal, found
89134
}

storage_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestLastStoredReading(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
csv string // file contents; empty string = don't create file
13+
noFile bool
14+
divisor float64
15+
wantVal float64
16+
wantOK bool
17+
}{
18+
{
19+
name: "file does not exist",
20+
noFile: true,
21+
wantOK: false,
22+
},
23+
{
24+
name: "empty file",
25+
csv: "",
26+
wantOK: false,
27+
},
28+
{
29+
name: "single valid row 4-column",
30+
csv: "img.jpg,00036596,2026-05-07T14:50:45Z,1\n",
31+
divisor: 1000,
32+
wantVal: 36.596,
33+
wantOK: true,
34+
},
35+
{
36+
name: "multiple rows returns last valid",
37+
csv: "a.jpg,10000,2026-01-01T00:00:00Z,1\nb.jpg,20000,2026-01-02T00:00:00Z,1\n",
38+
divisor: 1000,
39+
wantVal: 20,
40+
wantOK: true,
41+
},
42+
{
43+
name: "last row invalid skipped",
44+
csv: "a.jpg,10000,2026-01-01T00:00:00Z,1\nb.jpg,20000,2026-01-02T00:00:00Z,0\n",
45+
divisor: 1000,
46+
wantVal: 10,
47+
wantOK: true,
48+
},
49+
{
50+
name: "all rows invalid",
51+
csv: "a.jpg,10000,2026-01-01T00:00:00Z,0\nb.jpg,20000,2026-01-02T00:00:00Z,0\n",
52+
divisor: 1000,
53+
wantOK: false,
54+
},
55+
{
56+
name: "old 3-column format assumed valid",
57+
csv: "img.jpg,00036596,2026-05-07T14:50:45Z\n",
58+
divisor: 1000,
59+
wantVal: 36.596,
60+
wantOK: true,
61+
},
62+
{
63+
name: "mixed old and new format",
64+
csv: "a.jpg,10000,2026-01-01T00:00:00Z\nb.jpg,20000,2026-01-02T00:00:00Z,1\nc.jpg,30000,2026-01-03T00:00:00Z,0\n",
65+
divisor: 1000,
66+
wantVal: 20,
67+
wantOK: true,
68+
},
69+
{
70+
name: "unparseable reading skipped",
71+
csv: "a.jpg,10000,2026-01-01T00:00:00Z,1\nb.jpg,notanumber,2026-01-02T00:00:00Z,1\n",
72+
divisor: 1000,
73+
wantVal: 10,
74+
wantOK: true,
75+
},
76+
{
77+
name: "divisor applied",
78+
csv: "img.jpg,5000,2026-01-01T00:00:00Z,1\n",
79+
divisor: 100,
80+
wantVal: 50,
81+
wantOK: true,
82+
},
83+
{
84+
name: "row with too few columns skipped",
85+
csv: "onlyonefield\na.jpg,10000,2026-01-01T00:00:00Z,1\n",
86+
divisor: 1000,
87+
wantVal: 10,
88+
wantOK: true,
89+
},
90+
}
91+
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
dir := t.TempDir()
95+
csvPath := filepath.Join(dir, "readings.csv")
96+
97+
if !tt.noFile {
98+
if err := os.WriteFile(csvPath, []byte(tt.csv), 0644); err != nil {
99+
t.Fatal(err)
100+
}
101+
}
102+
103+
divisor := tt.divisor
104+
if divisor == 0 {
105+
divisor = 1
106+
}
107+
108+
gotVal, gotOK := lastStoredReading(csvPath, divisor)
109+
if gotOK != tt.wantOK {
110+
t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK)
111+
}
112+
if gotOK && gotVal != tt.wantVal {
113+
t.Errorf("val = %f, want %f", gotVal, tt.wantVal)
114+
}
115+
})
116+
}
117+
}

0 commit comments

Comments
 (0)