From b8da774b23c88a6053780dc4a0469d557de77629 Mon Sep 17 00:00:00 2001 From: Jay Ravani Date: Tue, 8 Sep 2026 19:02:18 +0200 Subject: [PATCH] feat(api): expose n_Apartment on GET /data/:code Downstream building models scale occupancy-driven loads (internal gains, electricity, hot water) by the number of dwellings in a building. TABULA carries that count as n_Apartment, and every country table already holds the column because the workbook loader imports every header, but GET /api/v1/data/:code did not expose it, so a caller had to assume one dwelling per building. Add N_Apartment to BuildingThematic. GetVariant selects every column and the struct mapper matches by json tag, so no repository, handler, SQL or migration change is needed. The column is REAL in PostgreSQL; the mapper's float32 case converts it to int, and every stored value across the 20 country tables is a whole number. It lands at tabula_data.BasicParameters.BuildingAppearance.n_Apartment. SFH and TH variants carry 1, MFH and AB carry the archetype's dwelling count, and the three DK.N.AB.09 variants carry 0 because the workbook gives no count for them. Closes #38 --- docs/openapi/openapi.yaml | 3 ++ .../repository/repository_integration_test.go | 29 +++++++++++++++++++ internal/db/repository/struct_mapper_test.go | 4 +++ internal/models/tabula.go | 1 + 4 files changed, 37 insertions(+) diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index c2238eb..bc8424d 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -420,6 +420,9 @@ components: Year2_Building are the earliest/latest construction year of this variant's period band (0 and 9999 are the same open-ended sentinels used by /api/v1/periods). + BasicParameters.BuildingAppearance.n_Apartment is the number of + dwellings in the archetype building: 1 for SFH and TH, the block's + dwelling count for MFH and AB, 0 where TABULA gives no count. properties: BasicParameters: type: object diff --git a/internal/db/repository/repository_integration_test.go b/internal/db/repository/repository_integration_test.go index 82d1ff0..850942f 100644 --- a/internal/db/repository/repository_integration_test.go +++ b/internal/db/repository/repository_integration_test.go @@ -125,6 +125,16 @@ func seedFixtures(ctx context.Context, pool *pgxpool.Pool) error { ('AT.N.MFH.02.Gen', 'AT.02', 1919, 1944), ('AT.N.TH.01.Gen', 'AT.01', 0, 1918), ('AT.N.TH.05.Gen', 'AT.05', 2000, 9999)`, + // netherlands carries the dwelling count. REAL matches the type the + // workbook loader gives the column in every country table. + `CREATE TABLE tabula.netherlands ( + id SERIAL PRIMARY KEY, + "Code_BuildingVariant" VARCHAR, + "n_Apartment" REAL + )`, + `INSERT INTO tabula.netherlands ("Code_BuildingVariant", "n_Apartment") VALUES + ('NL.N.AB.03.Gen.ReEx.001.001', 15), + ('NL.N.SFH.01.Gen.ReEx.001.001', 1)`, } for _, stmt := range statements { if _, err := pool.Exec(ctx, stmt); err != nil { @@ -257,6 +267,25 @@ func TestTabulaRepository_GetVariant_success(t *testing.T) { } } +func TestTabulaRepository_GetVariant_nApartment(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + for _, tc := range []struct { + code string + want int + }{ + {"NL.N.AB.03.Gen.ReEx.001.001", 15}, + {"NL.N.SFH.01.Gen.ReEx.001.001", 1}, + } { + params, _, _, err := r.GetVariant(context.Background(), "netherlands", tc.code) + if err != nil { + t.Fatalf("%s: unexpected error: %v", tc.code, err) + } + if got := params.BasicParameters.BuildingAppearance.N_Apartment; got != tc.want { + t.Errorf("%s: N_Apartment = %d, want %d", tc.code, got, tc.want) + } + } +} + func TestTabulaRepository_GetVariant_notFound(t *testing.T) { r := repository.NewTabulaRepository(testPool, "tabula") _, _, _, err := r.GetVariant(context.Background(), "germany", "DE.N.SFH.99.Gen") diff --git a/internal/db/repository/struct_mapper_test.go b/internal/db/repository/struct_mapper_test.go index d79d9b5..53c5335 100644 --- a/internal/db/repository/struct_mapper_test.go +++ b/internal/db/repository/struct_mapper_test.go @@ -30,6 +30,7 @@ func TestPopulateStructFromMap_setsFieldsByJSONTag(t *testing.T) { "n_air_infiltration": float64(0.5), "Year1_Building": int32(0), "Year2_Building": int32(1918), + "n_Apartment": float32(15), } populateStructFromMap(data, dataMap) @@ -58,6 +59,9 @@ func TestPopulateStructFromMap_setsFieldsByJSONTag(t *testing.T) { if data.BasicParameters.BuildingAppearance.Year2_Building != 1918 { t.Errorf("Year2_Building = %d, want 1918", data.BasicParameters.BuildingAppearance.Year2_Building) } + if data.BasicParameters.BuildingAppearance.N_Apartment != 15 { + t.Errorf("N_Apartment = %d, want 15", data.BasicParameters.BuildingAppearance.N_Apartment) + } } func TestPopulateStructFromMap_missingAndNilKeysAreSkipped(t *testing.T) { diff --git a/internal/models/tabula.go b/internal/models/tabula.go index 9778800..82b3ac0 100644 --- a/internal/models/tabula.go +++ b/internal/models/tabula.go @@ -55,6 +55,7 @@ type BuildingThematic struct { Code_ComplexFootprint string `json:"Code_ComplexFootprint"` // Complexity of building footprint (e.g. "Simple", "Standard", "Complex") Code_ComplexRoof string `json:"Code_ComplexRoof"` // Complexity of roof shape (e.g. "Simple", "Standard", "Complex") N_Storey int `json:"n_Storey"` // Number of storeys (used for calculating conditioned volume if not provided in dataset) + N_Apartment int `json:"n_Apartment"` // Number of dwellings in the archetype building (1 for SFH and TH, 0 where TABULA gives no count) H_room float64 `json:"h_room"` // Room height in meters (used for calculating conditioned volume if not provided in dataset) Code_AtticCond string `json:"Code_AtticCond"` // Attic condition code Code_CellarCond string `json:"Code_CellarCond"` // Cellar condition code