diff --git a/docs/api.md b/docs/api.md index 8b3c821..f196bd3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,6 +22,8 @@ ignis has no authentication of its own. The reverse proxy in front of it is the Step 2 is optional. Call it when you need to inspect or report the inputs; `calculate` uses them either way. +If you have a construction year rather than a period code, send `year=` in place of `period=` at step 1 and ignis resolves it. Exactly one of the two is required. `GET /api/v1/periods/{country}` lists a country's bands as `{ period, year_from, year_to }`, oldest first, with `year_from` 0 and `year_to` 9999 marking the open-ended bands. + ## Overriding archetype inputs `calculate` accepts an optional JSON body. Every field is independent and optional: send only what you want to change, and every other input still comes from the archetype's TABULA defaults. diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 70b3bc3..6096b83 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -102,7 +102,11 @@ paths: summary: Match refurbishment variants for a building type and period description: > Returns the refurbishment levels for one building type and construction - period, ordered from existing state to most refurbished. + period, ordered from existing state to most refurbished. Give the period + either directly (period) or as a construction year (year), which ignis + resolves to the period whose band contains it for that country and type. + A year with no matching archetype for that type returns an empty data + list. parameters: - $ref: "#/components/parameters/CountryIso2" - name: type @@ -115,11 +119,22 @@ paths: example: SFH - name: period in: query - required: true - description: TABULA construction-period code + required: false + description: > + TABULA construction-period code. Exactly one of period or year is + required. schema: type: string example: "01" + - name: year + in: query + required: false + description: > + Construction year, resolved to a period. Exactly one of period or + year is required. + schema: + type: integer + example: 1975 responses: "200": description: Matched refurbishment levels @@ -132,6 +147,29 @@ paths: "403": $ref: "#/components/responses/Forbidden" + /api/v1/periods/{country_iso2}: + get: + tags: [Variants] + operationId: listPeriods + summary: List a country's construction-year bands + description: > + The construction periods for a country, oldest first. year_from 0 is + open-ended (oldest band); year_to 9999 is open-ended (newest band). + period is the value the match endpoint's period parameter takes. + parameters: + - $ref: "#/components/parameters/CountryIso2" + responses: + "200": + description: Construction-year bands for the country + content: + application/json: + schema: + $ref: "#/components/schemas/PeriodsResponse" + "400": + $ref: "#/components/responses/BadRequest" + "403": + $ref: "#/components/responses/Forbidden" + /api/v1/data/{code}: get: tags: [Data] @@ -238,11 +276,12 @@ components: responses: BadRequest: description: > - Invalid input (unknown country, malformed code, or an invalid - CalculateRequest override — A_ref, h_room, n_Storey, or c_m not greater - than 0, HeatingDays, I_Sol_*, n_air_infiltration, or n_air_use negative, - a thermal-bridging ΔU negative, or a surface with an unknown type / - non-positive area / non-positive u_value) + Invalid input (unknown country, malformed code, a match request missing + type or giving neither or both of period and year or a non-integer year, + or an invalid CalculateRequest override — A_ref, h_room, n_Storey, or c_m + not greater than 0, HeatingDays, I_Sol_*, n_air_infiltration, or + n_air_use negative, a thermal-bridging ΔU negative, or a surface with an + unknown type / non-positive area / non-positive u_value) content: application/json: schema: @@ -321,6 +360,35 @@ components: example: Existing state required: [code, label] + PeriodsResponse: + type: object + properties: + country: + type: string + example: germany + data: + type: array + items: + $ref: "#/components/schemas/ConstructionPeriod" + required: [country, data] + + ConstructionPeriod: + type: object + properties: + period: + type: string + description: Period code, as taken by the match endpoint's period parameter + example: "03" + year_from: + type: integer + description: First year of the band; 0 means open-ended + example: 1958 + year_to: + type: integer + description: Last year of the band; 9999 means open-ended + example: 1968 + required: [period, year_from, year_to] + DataResponse: type: object properties: diff --git a/internal/api/handler/calculation_test.go b/internal/api/handler/calculation_test.go index c5d675e..beced50 100644 --- a/internal/api/handler/calculation_test.go +++ b/internal/api/handler/calculation_test.go @@ -17,9 +17,11 @@ import ( // mockRepo implements repository.TabulaReader for use in handler tests. type mockRepo struct { - listVariants func(ctx context.Context, tableName string) ([]string, error) - matchVariants func(ctx context.Context, tableName, prefix string) ([]string, error) - getVariant func(ctx context.Context, tableName, code string) (*models.TabulaBuildingParameters, string, float64, error) + listVariants func(ctx context.Context, tableName string) ([]string, error) + matchVariants func(ctx context.Context, tableName, prefix string) ([]string, error) + resolvePeriodByYear func(ctx context.Context, tableName, typePrefix string, year int) (string, error) + listPeriods func(ctx context.Context, tableName string) ([]repository.ConstructionPeriod, error) + getVariant func(ctx context.Context, tableName, code string) (*models.TabulaBuildingParameters, string, float64, error) } func (m *mockRepo) ListVariants(ctx context.Context, tableName string) ([]string, error) { @@ -33,6 +35,20 @@ func (m *mockRepo) MatchVariants(ctx context.Context, tableName, prefix string) return nil, nil } +func (m *mockRepo) ResolvePeriodByYear(ctx context.Context, tableName, typePrefix string, year int) (string, error) { + if m.resolvePeriodByYear != nil { + return m.resolvePeriodByYear(ctx, tableName, typePrefix, year) + } + return "", nil +} + +func (m *mockRepo) ListPeriods(ctx context.Context, tableName string) ([]repository.ConstructionPeriod, error) { + if m.listPeriods != nil { + return m.listPeriods(ctx, tableName) + } + return nil, nil +} + func (m *mockRepo) GetVariant(ctx context.Context, tableName, code string) (*models.TabulaBuildingParameters, string, float64, error) { return m.getVariant(ctx, tableName, code) } diff --git a/internal/api/handler/data.go b/internal/api/handler/data.go index 392b807..2ebad59 100644 --- a/internal/api/handler/data.go +++ b/internal/api/handler/data.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "time" @@ -60,8 +61,10 @@ func refurbishmentLabel(index int) string { } // MatchVariants returns all refurbishment variants for a building type and construction period. -// Query params: type (e.g. SFH), period (e.g. 01). Both are required. -// The response is ordered from existing state to most-refurbished. +// Query params: type (e.g. SFH) is required; exactly one of period (e.g. 01) or +// year (e.g. 1975) must be given. With year, ignis resolves the period whose band +// contains it for that country and type. The response is ordered from existing +// state to most-refurbished. func (h *Handler) MatchVariants(c *gin.Context) { isoCode := strings.ToUpper(strings.TrimSpace(c.Param("country_iso2"))) tableName, err := tableNameFromISO(isoCode) @@ -72,18 +75,44 @@ func (h *Handler) MatchVariants(c *gin.Context) { buildingType := strings.ToUpper(strings.TrimSpace(c.Query("type"))) period := strings.TrimSpace(c.Query("period")) + yearParam := strings.TrimSpace(c.Query("year")) - if buildingType == "" || period == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "query params 'type' and 'period' are required"}) + if buildingType == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "query param 'type' is required"}) + return + } + if (period == "") == (yearParam == "") { + c.JSON(http.StatusBadRequest, gin.H{"error": "exactly one of 'period' or 'year' is required"}) return } - - // TABULA codes follow CC.N.TYPE.PERIOD.VariantSuffix — N is the national dataset identifier. - prefix := fmt.Sprintf("%s.N.%s.%s", isoCode, buildingType, period) ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() + // TABULA codes follow CC.N.TYPE.PERIOD.VariantSuffix — N is the national dataset identifier. + typePrefix := fmt.Sprintf("%s.N.%s", isoCode, buildingType) + + if yearParam != "" { + year, convErr := strconv.Atoi(yearParam) + if convErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("query param 'year' must be an integer, got %q", yearParam)}) + return + } + period, err = h.repo.ResolvePeriodByYear(ctx, tableName, typePrefix, year) + if err != nil { + utils.Error.Printf("failed to resolve period for %s year %d: %v", typePrefix, year, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve construction period"}) + return + } + if period == "" { + // No archetype of this type covers the year — an empty match list, not an error. + c.JSON(http.StatusOK, gin.H{"country": tableName, "prefix": typePrefix, "data": []any{}}) + return + } + } + + prefix := fmt.Sprintf("%s.%s", typePrefix, period) + codes, err := h.repo.MatchVariants(ctx, tableName, prefix) if err != nil { utils.Error.Printf("failed to match variants for %s: %v", prefix, err) @@ -107,6 +136,32 @@ func (h *Handler) MatchVariants(c *gin.Context) { }) } +// ListPeriods returns the country's construction-year bands, oldest first. +// year_from 0 means open-ended (oldest band); year_to 9999 means open-ended (newest band). +func (h *Handler) ListPeriods(c *gin.Context) { + isoCode := strings.ToUpper(strings.TrimSpace(c.Param("country_iso2"))) + tableName, err := tableNameFromISO(isoCode) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + periods, err := h.repo.ListPeriods(ctx, tableName) + if err != nil { + utils.Error.Printf("failed to load construction periods for %s: %v", tableName, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to query construction periods"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "country": tableName, + "data": periods, + }) +} + // GetVariantData retrieves TABULA data for a specific building variant. func (h *Handler) GetVariantData(c *gin.Context) { variantCode := strings.TrimSpace(c.Param("code")) diff --git a/internal/api/handler/handler_helpers_test.go b/internal/api/handler/handler_helpers_test.go index b01d9c7..cb82709 100644 --- a/internal/api/handler/handler_helpers_test.go +++ b/internal/api/handler/handler_helpers_test.go @@ -7,6 +7,8 @@ import ( "net/http" "strings" "testing" + + "github.com/thd-spatial-ai/ignis/internal/db/repository" ) // --- isoFromVariantCode --- @@ -218,3 +220,148 @@ func TestNew_constructsHandlerWithRepo(t *testing.T) { t.Fatal("expected New to return a Handler with a non-nil repo") } } + +// --- MatchVariants: year resolution --- + +func TestMatchVariants_byYear_resolvesPeriodAndReturnsVariants(t *testing.T) { + var gotPrefix string + var gotYear int + mock := &mockRepo{ + resolvePeriodByYear: func(_ context.Context, _, typePrefix string, year int) (string, error) { + gotPrefix, gotYear = typePrefix, year + return "03", nil + }, + matchVariants: func(_ context.Context, _, prefix string) ([]string, error) { + if prefix != "DE.N.SFH.03" { + t.Errorf("MatchVariants prefix = %q, want DE.N.SFH.03", prefix) + } + return []string{"DE.N.SFH.03.Gen", "DE.N.SFH.03.ReEx"}, nil + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/variants/DE/match?type=SFH&year=1975", "/variants/:country_iso2/match", h.MatchVariants, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String()) + } + if gotPrefix != "DE.N.SFH" || gotYear != 1975 { + t.Errorf("ResolvePeriodByYear called with (%q, %d), want (\"DE.N.SFH\", 1975)", gotPrefix, gotYear) + } + var resp struct { + Data []struct { + Code string `json:"code"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(resp.Data) != 2 { + t.Fatalf("expected 2 entries, got %d", len(resp.Data)) + } +} + +func TestMatchVariants_bothYearAndPeriod_returns400(t *testing.T) { + h := newTestHandler(&mockRepo{}) + w := serve(http.MethodGet, "/variants/DE/match?type=SFH&period=03&year=1975", "/variants/:country_iso2/match", h.MatchVariants, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 when both period and year given, got %d", w.Code) + } +} + +func TestMatchVariants_nonIntegerYear_returns400(t *testing.T) { + h := newTestHandler(&mockRepo{}) + w := serve(http.MethodGet, "/variants/DE/match?type=SFH&year=nineteen", "/variants/:country_iso2/match", h.MatchVariants, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for non-integer year, got %d", w.Code) + } +} + +func TestMatchVariants_yearWithNoMatchingArchetype_returns200EmptyList(t *testing.T) { + mock := &mockRepo{ + resolvePeriodByYear: func(_ context.Context, _, _ string, _ int) (string, error) { + return "", nil // no band for this type contains the year + }, + matchVariants: func(_ context.Context, _, _ string) ([]string, error) { + t.Error("MatchVariants must not be called when no period resolves") + return nil, nil + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/variants/DE/match?type=SFH&year=1750", "/variants/:country_iso2/match", h.MatchVariants, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String()) + } + var resp struct { + Data []any `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(resp.Data) != 0 { + t.Errorf("expected empty data list, got %d entries", len(resp.Data)) + } +} + +func TestMatchVariants_resolvePeriodError_returns500(t *testing.T) { + mock := &mockRepo{ + resolvePeriodByYear: func(_ context.Context, _, _ string, _ int) (string, error) { + return "", errors.New("connection refused") + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/variants/DE/match?type=SFH&year=1975", "/variants/:country_iso2/match", h.MatchVariants, nil) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} + +// --- ListPeriods handler --- + +func TestListPeriods_returnsBands(t *testing.T) { + mock := &mockRepo{ + listPeriods: func(_ context.Context, _ string) ([]repository.ConstructionPeriod, error) { + return []repository.ConstructionPeriod{ + {Period: "01", YearFrom: 0, YearTo: 1859}, + {Period: "02", YearFrom: 1860, YearTo: 1918}, + {Period: "12", YearFrom: 2016, YearTo: 9999}, + }, nil + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/periods/DE", "/periods/:country_iso2", h.ListPeriods, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String()) + } + var resp struct { + Data []repository.ConstructionPeriod `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(resp.Data) != 3 { + t.Fatalf("expected 3 bands, got %d", len(resp.Data)) + } + if resp.Data[0].YearFrom != 0 || resp.Data[2].YearTo != 9999 { + t.Errorf("open-ended sentinels not preserved: %+v", resp.Data) + } +} + +func TestListPeriods_unknownCountry_returns400(t *testing.T) { + h := newTestHandler(&mockRepo{}) + w := serve(http.MethodGet, "/periods/ZZ", "/periods/:country_iso2", h.ListPeriods, nil) + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for unknown country, got %d", w.Code) + } +} + +func TestListPeriods_repoError_returns500(t *testing.T) { + mock := &mockRepo{ + listPeriods: func(_ context.Context, _ string) ([]repository.ConstructionPeriod, error) { + return nil, errors.New("connection refused") + }, + } + h := newTestHandler(mock) + w := serve(http.MethodGet, "/periods/DE", "/periods/:country_iso2", h.ListPeriods, nil) + if w.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", w.Code) + } +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index fe67457..21a9254 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -24,6 +24,7 @@ func RegisterRoutes(r *gin.Engine, h *handler.Handler) { v1.GET("/data/:code", h.GetVariantData) v1.GET("/variants/:country_iso2", h.GetVariants) v1.GET("/variants/:country_iso2/match", h.MatchVariants) + v1.GET("/periods/:country_iso2", h.ListPeriods) v1.GET("/fields", h.GetFieldMetadata) // POST diff --git a/internal/api/router/router_test.go b/internal/api/router/router_test.go index 44d2847..5539226 100644 --- a/internal/api/router/router_test.go +++ b/internal/api/router/router_test.go @@ -38,10 +38,11 @@ func TestRegisterRoutes_registersExpectedRoutes(t *testing.T) { want := map[string]bool{ "GET /favicon.ico": false, - "GET /ignis/health": false, + "GET /ignis/health": false, "GET /api/v1/data/:code": false, "GET /api/v1/variants/:country_iso2": false, "GET /api/v1/variants/:country_iso2/match": false, + "GET /api/v1/periods/:country_iso2": false, "GET /api/v1/fields": false, "POST /api/v1/calculate/:code": false, } diff --git a/internal/db/repository/interfaces.go b/internal/db/repository/interfaces.go index 21020d3..e16f580 100644 --- a/internal/db/repository/interfaces.go +++ b/internal/db/repository/interfaces.go @@ -16,6 +16,16 @@ type TabulaReader interface { // prefix should be "CC.N.TYPE.PERIOD" (e.g. "DE.N.SFH.01"). MatchVariants(ctx context.Context, tableName, prefix string) ([]string, error) + // ResolvePeriodByYear returns the TABULA period index (e.g. "03") whose + // construction-year band contains year, scoped to one building type. + // typePrefix is "CC.N.TYPE" (e.g. "DE.N.SFH"). Returns "" if no band for + // that type contains the year. + ResolvePeriodByYear(ctx context.Context, tableName, typePrefix string, year int) (string, error) + + // ListPeriods returns the country's construction-year bands, oldest first. + // year_from 0 and year_to 9999 are passed through as the open-ended sentinels. + ListPeriods(ctx context.Context, tableName string) ([]ConstructionPeriod, error) + // GetVariant loads the full TABULA record for a specific building variant code. // Returns the building parameters, the variant code string, the reference q_h_nd, and any error. GetVariant(ctx context.Context, tableName, variantCode string) (*models.TabulaBuildingParameters, string, float64, error) diff --git a/internal/db/repository/repository_integration_test.go b/internal/db/repository/repository_integration_test.go index 1de34ec..324a495 100644 --- a/internal/db/repository/repository_integration_test.go +++ b/internal/db/repository/repository_integration_test.go @@ -106,6 +106,21 @@ func seedFixtures(ctx context.Context, pool *pgxpool.Pool) error { ('DE.N.SFH.01.Gen', 75.5, 185, 123.45), ('DE.N.SFH.01.ReEx', 75.5, 185, 98.70), ('DE.N.MFH.01.Gen', 200.0, 185, 88.10)`, + // austria carries the construction-year bands for the period-resolution tests. + // MFH deliberately has no 01 band, so year resolution is scoped to type. + `CREATE TABLE tabula.austria ( + id SERIAL PRIMARY KEY, + "Code_BuildingVariant" VARCHAR, + "Code_ConstructionYearClass" VARCHAR, + "Year1_Building" INTEGER, + "Year2_Building" INTEGER + )`, + `INSERT INTO tabula.austria ("Code_BuildingVariant", "Code_ConstructionYearClass", "Year1_Building", "Year2_Building") VALUES + ('AT.N.SFH.01.Gen', 'AT.01', 0, 1918), + ('AT.N.SFH.01.ReEx', 'AT.01', 0, 1918), + ('AT.N.SFH.02.Gen', 'AT.02', 1919, 1944), + ('AT.N.SFH.12.Gen', 'AT.12', 2009, 9999), + ('AT.N.MFH.02.Gen', 'AT.02', 1919, 1944)`, } for _, stmt := range statements { if _, err := pool.Exec(ctx, stmt); err != nil { @@ -164,6 +179,55 @@ func TestTabulaRepository_MatchVariants_noMatches(t *testing.T) { } } +func TestTabulaRepository_ResolvePeriodByYear(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + cases := []struct { + name string + typePrefix string + year int + want string + }{ + {"mid oldest band", "AT.N.SFH", 1900, "01"}, + {"upper boundary of oldest band", "AT.N.SFH", 1918, "01"}, + {"lower boundary of next band", "AT.N.SFH", 1919, "02"}, + {"open-ended newest band", "AT.N.SFH", 2050, "12"}, + {"type without that band", "AT.N.MFH", 1900, ""}, + {"year in no band for type", "AT.N.MFH", 3000, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := r.ResolvePeriodByYear(context.Background(), "austria", tc.typePrefix, tc.year) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Errorf("ResolvePeriodByYear(%q, %d) = %q, want %q", tc.typePrefix, tc.year, got, tc.want) + } + }) + } +} + +func TestTabulaRepository_ListPeriods(t *testing.T) { + r := repository.NewTabulaRepository(testPool, "tabula") + periods, err := r.ListPeriods(context.Background(), "austria") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []repository.ConstructionPeriod{ + {Period: "01", YearFrom: 0, YearTo: 1918}, + {Period: "02", YearFrom: 1919, YearTo: 1944}, + {Period: "12", YearFrom: 2009, YearTo: 9999}, + } + if len(periods) != len(want) { + t.Fatalf("periods = %+v, want %+v", periods, want) + } + for i := range want { + if periods[i] != want[i] { + t.Errorf("periods[%d] = %+v, want %+v", i, periods[i], want[i]) + } + } +} + func TestTabulaRepository_GetVariant_success(t *testing.T) { r := repository.NewTabulaRepository(testPool, "tabula") params, buildingID, expectedQHND, err := r.GetVariant(context.Background(), "germany", "DE.N.SFH.01.Gen") diff --git a/internal/db/repository/tabula_repository.go b/internal/db/repository/tabula_repository.go index c5d1f4a..61de22d 100644 --- a/internal/db/repository/tabula_repository.go +++ b/internal/db/repository/tabula_repository.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strconv" + "strings" "github.com/thd-spatial-ai/ignis/internal/models" @@ -84,6 +85,86 @@ func (r *TabulaRepository) MatchVariants(ctx context.Context, tableName, prefix return codes, nil } +// ConstructionPeriod is one TABULA construction-year band for a country. +type ConstructionPeriod struct { + Period string `json:"period"` + YearFrom int `json:"year_from"` + YearTo int `json:"year_to"` +} + +// periodFromYearClass turns a Code_ConstructionYearClass ("AT.01") into the bare +// period index ("01") used by Code_BuildingVariant and the match endpoint. +func periodFromYearClass(yearClass string) string { + if i := strings.LastIndex(yearClass, "."); i >= 0 { + return yearClass[i+1:] + } + return yearClass +} + +// ResolvePeriodByYear returns the period index whose construction-year band +// contains year, scoped to one building type. typePrefix is "CC.N.TYPE". +// Year1_Building 0 and Year2_Building 9999 are the open-ended sentinels; the +// plain range comparison covers both without special-casing. Returns "" when no +// band for that type contains the year. +func (r *TabulaRepository) ResolvePeriodByYear(ctx context.Context, tableName, typePrefix string, year int) (string, error) { + query := fmt.Sprintf( + `SELECT "Code_ConstructionYearClass" FROM %s + WHERE "Code_BuildingVariant" LIKE $1 + AND "Year1_Building" <= $2 AND "Year2_Building" >= $2 + LIMIT 1`, + r.qualifyTable(tableName), + ) + + var yearClass string + err := r.pool.QueryRow(ctx, query, typePrefix+".%", year).Scan(&yearClass) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("failed to resolve period for %s year %d: %w", typePrefix, year, err) + } + + return periodFromYearClass(yearClass), nil +} + +// ListPeriods returns the country's distinct construction-year bands, oldest first. +func (r *TabulaRepository) ListPeriods(ctx context.Context, tableName string) ([]ConstructionPeriod, error) { + query := fmt.Sprintf( + `SELECT DISTINCT "Code_ConstructionYearClass", "Year1_Building", "Year2_Building" + FROM %s + WHERE "Code_ConstructionYearClass" IS NOT NULL + AND "Year1_Building" IS NOT NULL AND "Year2_Building" IS NOT NULL + ORDER BY "Year1_Building"`, + r.qualifyTable(tableName), + ) + + rows, err := r.pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to query construction periods: %w", err) + } + defer rows.Close() + + var periods []ConstructionPeriod + for rows.Next() { + var yearClass string + var from, to int + if err := rows.Scan(&yearClass, &from, &to); err != nil { + return nil, fmt.Errorf("failed to scan construction period: %w", err) + } + periods = append(periods, ConstructionPeriod{ + Period: periodFromYearClass(yearClass), + YearFrom: from, + YearTo: to, + }) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to iterate construction periods: %w", err) + } + + return periods, nil +} + // GetVariant loads the full TABULA record and key metadata for a specific building variant. func (r *TabulaRepository) GetVariant(ctx context.Context, tableName, buildingCode string) (*models.TabulaBuildingParameters, string, float64, error) { query := fmt.Sprintf(`SELECT * FROM %s WHERE "Code_BuildingVariant" = $1 LIMIT 1`, r.qualifyTable(tableName))