From 1446feec7e265306ac09ab88d118cf18cced34bf Mon Sep 17 00:00:00 2001 From: Jay Ravani Date: Fri, 2 Oct 2026 14:45:10 +0200 Subject: [PATCH 1/2] chore(gitignore): anchor the binary entries to the repository root The bare entries app, build_db and validate were meant for binaries built at the repository root, but they also matched the cmd/build_db and cmd/validate directories. A new file there was ignored, and `git add` on a tracked one warned. Anchoring them with a leading slash keeps the root binaries ignored and the source directories visible. --- .gitignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index f244300..0607e19 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ # Binaries bin/ -app -build_db -validate +/app +/build_db +/validate *.exe *.exe~ dist/ From 1848d87b4d8e16c1523892e70bcc0fdcc49f4216 Mon Sep 17 00:00:00 2001 From: Jay Ravani Date: Fri, 2 Oct 2026 14:45:10 +0200 Subject: [PATCH 2/2] fix(validate): report results, and exit 1 under -strict on a failure validate computed a pass or fail for every building but printed none of it: the summary and the failure listing were commented out, and the tool exited 0 whatever the results. It now prints the pass count per country, one line per failing building with its calculated and expected q_h_nd and percentage error (or the pipeline error), and the overall count. Results are ordered by row ID so two runs print the same output. A plain run exits 0. With -strict it exits 1 when any building is outside the tolerance. The default stays 0 because Spain currently fails 23 rows (#47), which would otherwise fail every run. The configuration is now loaded in main instead of at package init, which panicked without DB_PASSWORD and made the package untestable. printTestResult is removed; it was only called from the commented-out block. Closes #51 --- cmd/validate/main.go | 146 +++++++++++++++++++------------------- cmd/validate/main_test.go | 54 ++++++++++++++ docs/getting-started.md | 2 +- docs/validation.md | 7 +- 4 files changed, 134 insertions(+), 75 deletions(-) create mode 100644 cmd/validate/main_test.go diff --git a/cmd/validate/main.go b/cmd/validate/main.go index a46bf8d..1c084b6 100644 --- a/cmd/validate/main.go +++ b/cmd/validate/main.go @@ -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" @@ -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) @@ -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) @@ -119,7 +177,7 @@ 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) { @@ -127,45 +185,15 @@ func testAllBuildings(pool *pgxpool.Pool, cfg config.DBConfig, tableName string) 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 { @@ -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 diff --git a/cmd/validate/main_test.go b/cmd/validate/main_test.go new file mode 100644 index 0000000..7f682aa --- /dev/null +++ b/cmd/validate/main_test.go @@ -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) + } + } +} diff --git a/docs/getting-started.md b/docs/getting-started.md index d4d7cc6..2e87ebb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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. --- diff --git a/docs/validation.md b/docs/validation.md index ab99702..5e2e425 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -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.