Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Binaries
bin/
app
build_db
validate
/app
/build_db
/validate
*.exe
*.exe~
dist/
Expand Down
146 changes: 73 additions & 73 deletions cmd/validate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"github.com/thd-spatial-ai/ignis/internal/config"
"github.com/thd-spatial-ai/ignis/internal/models"
"github.com/thd-spatial-ai/ignis/internal/pipeline"
"io"
"log"
"math"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"time"

Expand All @@ -32,13 +35,17 @@ type TestResult struct {

const tolerancePercent = 2.5 // percent, allowed deviation from the TABULA reference q_h_nd

var cfg = config.LoadConfig()
// cfg is loaded in main, not at package init, so the package can be tested
// without a database configuration.
var cfg config.Config

func main() {
strict := flag.Bool("strict", false, "exit 1 when any building falls outside the tolerance")
flag.Parse()

fmt.Println("=== ignis Validation Tool ===")
startTime := time.Now()
// Load configuration
cfg := config.LoadConfig()
cfg = config.LoadConfig()

fmt.Printf("Database: %s@%s:%s/%s\n\n", cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name)

Expand Down Expand Up @@ -84,20 +91,71 @@ func main() {
fmt.Printf(" - %s\n", tn)
}

// Test all buildings
totalRows := 0
results := make(map[string][]TestResult, len(tableNames))
for _, tableName := range tableNames {
results[tableName] = testAllBuildings(pool, *cfg.DB, tableName)
}

failed := report(os.Stdout, tableNames, results)
fmt.Printf("\n=== Validation completed in %s ===\n", time.Since(startTime))

pool.Close()
os.Exit(exitCode(failed, *strict))
}

// report writes each table's pass count, then every failing building, then
// the overall count, and returns the number of buildings that did not pass.
// A building with an ErrorMessage counts as failed.
func report(w io.Writer, tableNames []string, results map[string][]TestResult) int {
fmt.Fprintf(w, "\nPass rate per table (tolerance %.1f%%):\n", tolerancePercent)
var failures []string
total, passed := 0, 0
for _, tableName := range tableNames {
// fmt.Printf("\n=== Testing buildings in table: %s ===\n", tableName)
tablePassed := 0
for _, r := range results[tableName] {
switch {
case r.ErrorMessage != "":
failures = append(failures, fmt.Sprintf(" %-15s %-35s %s", tableName, r.BuildingID, r.ErrorMessage))
case r.Passed:
tablePassed++
default:
failures = append(failures, fmt.Sprintf(" %-15s %-35s calculated %10.2f expected %10.2f error %.2f%%",
tableName, r.BuildingID, r.CalculatedQHND, r.ExpectedQHND, r.PercentError))
}
}
n := len(results[tableName])
total += n
passed += tablePassed
fmt.Fprintf(w, " %-15s %4d/%-4d %6.1f%%\n", tableName, tablePassed, n, percent(tablePassed, n))
}

if len(failures) > 0 {
fmt.Fprintf(w, "\nFailures (%d):\n", len(failures))
for _, line := range failures {
fmt.Fprintln(w, line)
}
}
fmt.Fprintf(w, "\nTotal: %d/%d passed (%.1f%%)\n", passed, total, percent(passed, total))
return total - passed
}

totalRows += testAllBuildings(pool, *cfg.DB, tableName)
func percent(part, whole int) float64 {
if whole == 0 {
return 0
}
return float64(part) / float64(whole) * 100
}

elapsed := time.Since(startTime)
fmt.Printf("\n=== Validation completed in %s ===\n", elapsed)
fmt.Printf("Total buildings tested across all tables: %d\n", totalRows)
// exitCode is 1 only under -strict with at least one failure, so a plain run
// reports failures without failing the caller.
func exitCode(failed int, strict bool) int {
if strict && failed > 0 {
return 1
}
return 0
}

func testAllBuildings(pool *pgxpool.Pool, cfg config.DBConfig, tableName string) int {
func testAllBuildings(pool *pgxpool.Pool, cfg config.DBConfig, tableName string) []TestResult {
// Get all row IDs
query := fmt.Sprintf(`SELECT id FROM %s.%s ORDER BY id`, cfg.Schemas.Tabula, tableName)
rows, err := pool.Query(context.Background(), query)
Expand All @@ -119,53 +177,23 @@ func testAllBuildings(pool *pgxpool.Pool, cfg config.DBConfig, tableName string)
// Run tests in parallel
var results []TestResult
resultsChan := make(chan TestResult, len(rowIDs))

// Launch goroutines for parallel execution
for _, rowID := range rowIDs {
go func(id int) {
result := runPipelineTest(pool, tableName, id)
resultsChan <- result
}(rowID)
}

// Collect results
for range rowIDs {
results = append(results, <-resultsChan)
}
close(resultsChan)

// // Print summary
// fmt.Println("\n" + strings.Repeat("=", 60))
// fmt.Println("=== Test Summary ===")
// fmt.Println(strings.Repeat("=", 60))

passCount := 0
failCount := 0
for _, result := range results {
if result.Passed {
passCount++
} else {
failCount++
}
}

// fmt.Printf("Total buildings tested: %d\n", len(results))
// fmt.Printf("Passed: %d (%.1f%%)\n", passCount, float64(passCount)/float64(len(rowIDs))*100)
// fmt.Printf("Failed: %d (%.1f%%)\n", failCount, float64(failCount)/float64(len(rowIDs))*100)

// // Print detailed results for failed tests (limited to first 10)
// if failCount > 0 {
// fmt.Println("\n=== Failed Tests Details (showing first 10) ===")
// count := 0
// for _, result := range results {
// if !result.Passed && count < 10 {
// printTestResult(result)
// count++
// }
// }
// }

return len(results)
sort.Slice(results, func(i, j int) bool { return results[i].RowID < results[j].RowID })
return results
}

func runPipelineTest(pool *pgxpool.Pool, tableName string, rowID int) TestResult {
Expand Down Expand Up @@ -411,34 +439,6 @@ func setFieldValue(field reflect.Value, value interface{}) {
}
}

// printTestResult prints detailed test result information
func printTestResult(result TestResult) {
separator := strings.Repeat("=", 60)
fmt.Println("\n" + separator)
fmt.Printf("Row ID: %d | Building: %s\n", result.RowID, result.BuildingID)
fmt.Println(separator)

if result.ErrorMessage != "" {
fmt.Printf("Status: ✗ FAILED\n")
fmt.Printf("Error: %s\n", result.ErrorMessage)
fmt.Println(separator)
return
}

if result.Passed {
fmt.Printf("Status: o PASSED\n")
} else {
fmt.Printf("Status: x FAILED\n")
}

fmt.Printf("\nResults:\n")
fmt.Printf(" Calculated q_h_nd: %.6f kWh/(m²·a)\n", result.CalculatedQHND)
fmt.Printf(" Expected q_h_nd: %.6f kWh/(m²·a)\n", result.ExpectedQHND)
fmt.Printf(" Difference: %.6f kWh/(m²·a)\n", result.Difference)
fmt.Printf(" Percent Error: %.4f%%\n", result.PercentError)
fmt.Println(separator)
}

// saveTabulaModelAsJSON saves the TabulaBuildingParameters as a JSON file
func saveTabulaModelAsJSON(tabulaData *models.TabulaBuildingParameters, buildingID string, rowID int) error {
// Create output directory if it doesn't exist
Expand Down
54 changes: 54 additions & 0 deletions cmd/validate/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package main

import (
"bytes"
"strings"
"testing"
)

func TestReport_countsPerTableAndListsFailures(t *testing.T) {
results := map[string][]TestResult{
"germany": {
{BuildingID: "DE.A", CalculatedQHND: 100, ExpectedQHND: 100, Passed: true},
},
"spain": {
{BuildingID: "ES.A", CalculatedQHND: 50, ExpectedQHND: 50, Passed: true},
{BuildingID: "ES.B", CalculatedQHND: 97, ExpectedQHND: 100, PercentError: 3, Passed: false},
{BuildingID: "ES.C", ErrorMessage: "pipeline error: boom"},
},
}

var out bytes.Buffer
failed := report(&out, []string{"germany", "spain"}, results)

if failed != 2 {
t.Errorf("failed = %d, want 2", failed)
}
got := out.String()
for _, want := range []string{"germany", "1/1", "spain", "1/3", "ES.B", "3.00%", "ES.C", "pipeline error: boom", "2/4"} {
if !strings.Contains(got, want) {
t.Errorf("report output missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "DE.A") {
t.Errorf("report lists a passing row:\n%s", got)
}
}

func TestExitCode_nonZeroOnlyWhenStrictAndFailing(t *testing.T) {
cases := []struct {
failed int
strict bool
want int
}{
{0, false, 0},
{0, true, 0},
{3, false, 0},
{3, true, 1},
}
for _, c := range cases {
if got := exitCode(c.failed, c.strict); got != c.want {
t.Errorf("exitCode(%d, %v) = %d, want %d", c.failed, c.strict, got, c.want)
}
}
}
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ Runs the full 17-level TABULA calculation pipeline against every row in the data
./bin/validate
```

This path only: there is no containerised `validate`. See the [validation report](validation.md) for current results.
Add `-strict` to exit 1 when any building is outside the tolerance. This path only: there is no containerised `validate`. See the [validation report](validation.md) for current results.

---

Expand Down
7 changes: 6 additions & 1 deletion docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,12 @@ The offset is uniform rather than scattered: -2.32% for references below 5 kWh/(

```bash
go build -buildvcs=false -o bin/ ./cmd/...
./bin/validate
./bin/validate # report only, exits 0
./bin/validate -strict # exits 1 if any building is outside ±2.5%
```

Requires a populated database (`./bin/build_db` must have been run first) and a valid `.env` file.

The output gives the pass count per country, one line per failing building with its calculated and expected `q_h_nd` and percentage error, and the overall pass count. A building whose pipeline run errors counts as failed and shows the error instead.

`validate` compares the pipeline's unrounded result, while `POST /api/v1/calculate/{code}` rounds `q_h_nd` to two decimals, so a building within a few hundredths of a percent of the tolerance can pass in one and fail in the other.
Loading