diff --git a/README.md b/README.md index 5c3729a..6d1cf1b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Go microservice implementing the **EN ISO 13790** annual heating energy demand calculation pipeline derived from [tabula-calculator.xlsx](https://episcope.eu/welcome/) *(Accessed on: 26.06.26)*. The calculation method has been documented in [TABULA CommonCalculationMethod](https://episcope.eu/fileadmin/tabula/public/docs/report/TABULA_CommonCalculationMethod.pdf) *(Accessed on: 26.06.2026)*. The tool covers all European building typologies across 20 countries defined by **TABULA & EPISCOPE (IEE Projects)**. -The results have been validated against the Excel Workbook output. So far **19/20 countries at 100% accuracy in total 2,091 / 2,147 buildings validated.** See the [validation report](docs/validation.md). +Results are validated against the workbook's own output: **19 of 20 countries at 100% accuracy, 2,091 of 2,147 buildings passing.** See the [validation report](docs/validation.md). --- @@ -14,7 +14,7 @@ The results have been validated against the Excel Workbook output. So far **19/2 | Dependency | Version | | ---------- | ------- | | Go | 1.26+ | -| PostgreSQL | >= 15 | +| PostgreSQL | 15 to 17 | --- @@ -38,7 +38,7 @@ Architecture documentation (arc42): under development, not yet published. ![test coverage](https://codecov.io/github/THD-Spatial-AI/ignis/graphs/icicle.svg?token=CTUZED1ELJ) -The top section in the graphic above, represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively. +In the graphic above, the top section is the whole project, the sections below it are folders, and the smallest are individual files. Slice size is the number of statements and slice colour is the coverage. > [!NOTE] > **AI usage disclaimer:** Some tests in this repository were written with the assistance of AI coding tools, then reviewed and validated by a maintainer before merging. diff --git a/cmd/validate/main.go b/cmd/validate/main.go index 564171e..a46bf8d 100644 --- a/cmd/validate/main.go +++ b/cmd/validate/main.go @@ -30,7 +30,7 @@ type TestResult struct { ErrorMessage string } -const tolerancePercent = 2.5 // 2% tolerance for heating demand cross-country variations +const tolerancePercent = 2.5 // percent, allowed deviation from the TABULA reference q_h_nd var cfg = config.LoadConfig() @@ -210,7 +210,7 @@ func runPipelineTest(pool *pgxpool.Pool, tableName string, rowID int) TestResult result.PercentError = 100.0 } - // Determine if test passed (allow 2% tolerance for cross-country variations) + // Determine if test passed result.Passed = result.PercentError <= tolerancePercent return result diff --git a/data/README.md b/data/README.md index f986bdb..9945ded 100644 --- a/data/README.md +++ b/data/README.md @@ -7,12 +7,12 @@ This directory contains the TABULA Webtool workbook used to seed the heat demand | Field | Detail | |-------|--------| | **File** | `tabula-calculator.xlsx` | -| **Source** | TABULA Webtool — [building-typology.eu](https://webtool.building-typology.eu/) | +| **Source** | TABULA Webtool, [building-typology.eu](https://webtool.building-typology.eu/) | | **Author** | Institut Wohnen und Umwelt (IWU), Darmstadt, Germany | | **Project** | Intelligent Energy Europe, IEE/09/739/SI2.558245 | | **License** | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | -The workbook contains per-country building typology data (U-values, areas, infiltration rates, climate parameters) and reference heating demand values (`q_h_nd`) used to validate the calculation pipeline within ±2.5 %. +The workbook contains per-country building typology data (U-values, areas, infiltration rates, climate parameters) and reference heating demand values (`q_h_nd`) used to validate the calculation pipeline within ±2.5%. **Citation:** @@ -22,27 +22,27 @@ This file is distributed under the same CC BY 4.0 terms as the original TABULA d ## tabula-calculator-lite.xlsx -A trimmed derivative of `tabula-calculator.xlsx`, created for this project and published alongside it on GitHub. `build_db` only ever reads the `Calc.Set.Building` sheet (see `internal/db/table_constructor.go`) — every other sheet in the full TABULA Webtool workbook (country-specific system/demo calculations, charts, auxiliary climate tables, etc.) is dead weight for this tool's purposes, and made up the majority of the original file's size. +A trimmed derivative of `tabula-calculator.xlsx`, created for this project and published alongside it on GitHub. `build_db` reads only the `Calc.Set.Building` sheet (see `internal/db/table_constructor.go`). Every other sheet in the full TABULA Webtool workbook (country-specific system and demo calculations, charts, auxiliary climate tables) is unused here and made up most of the original file's size. Sheets kept: -- **`Info`** — the original workbook's own attribution/info sheet, kept as-is so provenance travels with the file itself, not just this README. -- **`Calc.Set.Building`** — the only sheet `build_db` actually reads. -- **`BlankSheet`** (hidden) — a template artifact from the original workbook; harmless, left in rather than risking broken internal references by removing it. +- **`Info`**: the original workbook's attribution sheet, kept so provenance travels with the file rather than only in this README. +- **`Calc.Set.Building`**: the only sheet `build_db` reads. +- **`BlankSheet`** (hidden): a template artefact from the original workbook, left in place because removing it risks breaking internal references. -Result: **11 MB**, down from the original **28 MB** — everything else (roughly two-thirds of the file, including a single sheet, `Calc.Set.System`, that alone exceeded the size of the sheet actually used) was unused by this tool. +The result is **11 MB**, down from **28 MB**. The removed sheets were roughly two-thirds of the file, including `Calc.Set.System`, which alone was larger than the sheet in use. -This is a derivative of TABULA Webtool data and remains under the same **CC BY 4.0** license as the original — see attribution above and in [`ATTRIBUTIONS.md`](../ATTRIBUTIONS.md). It is what `environment/ignis-db.dockerfile` bakes into the `ignis-build-db` image (see that file's comments): the data is static source-of-truth reference data, read once to seed Postgres and never modified independently afterward, so shipping it inside the image — rather than mounting it in at runtime — makes a given image tag fully reproducible. +This is a derivative of TABULA Webtool data and remains under the same **CC BY 4.0** licence as the original; see the attribution above and in [`ATTRIBUTIONS.md`](../ATTRIBUTIONS.md). `environment/ignis-db.dockerfile` bakes it into the `ignis-build-db` image. The data is static reference data, read once to seed Postgres and never modified afterwards, so shipping it inside the image rather than mounting it at runtime makes a given image tag reproducible. ## Usage -The `build_db` binary reads whichever `.xlsx` it finds in this directory (`filepath.Glob("data/*.xlsx")`, first match alphabetically — currently `tabula-calculator-lite.xlsx`, since both files satisfy the same `Calc.Set.Building`-only requirement): +The `build_db` binary reads whichever `.xlsx` it finds in this directory (`filepath.Glob("data/*.xlsx")`, first match alphabetically, currently `tabula-calculator-lite.xlsx`, since both files satisfy the same `Calc.Set.Building` requirement): ```bash go build -o bin/build_db cmd/build_db/main.go ./bin/build_db ``` -Inside Docker (`environment/ignis-db.dockerfile`), only `tabula-calculator-lite.xlsx` is present in the image at all, so there's no ambiguity there. +Inside Docker (`environment/ignis-db.dockerfile`) only `tabula-calculator-lite.xlsx` is present, so there is no ambiguity. !!! warning "Destructive operation" Running `build_db` drops and recreates all TABULA country tables. Do not run against a database that holds production data without a backup. diff --git a/docs/api.md b/docs/api.md index 6356ba7..beefdff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,16 +1,20 @@ +--- +audience: developer +--- + # API reference The interactive reference lives in its own standalone page, [`openapi/index.html`](openapi/index.html), so it can be opened directly without running `mkdocs serve`. It renders [`openapi/openapi.yaml`](openapi/openapi.yaml); download that file to generate a client or import it into Postman. ## Authentication -ignis carries no credential and asks for none. Every endpoint is reachable by anything that can open a connection to it, so who that is gets decided at the network layer. Deploy it only on a network whose access you control, behind a platform that has already authenticated the end user. +ignis carries no credential and asks for none. Every endpoint is reachable by anything that can open a connection to it, so reachability is decided at the network layer. Deploy it only on a network whose access you control, behind a platform that has already authenticated the end user. !!! warning "Do not publish ignis on a public interface" - There is nothing in ignis to stop an unauthenticated caller. Keep it on an internal network, or behind something that authenticates for it. A user-facing login in front of your own system (EnerPlanET uses Keycloak) authenticates the user to *that system*, not to ignis; that system's backend then calls ignis on the user's behalf. + Nothing in ignis stops an unauthenticated caller. Keep it on an internal network, or behind something that authenticates for it. A user-facing login in front of a calling system (EnerPlanET uses Keycloak) authenticates the user to *that system*, not to ignis; that system's backend then calls ignis on the user's behalf. !!! note "Base URL" - Depends on which environment you started. `environment/http` publishes the app directly, so the base URL is `http://localhost:8088`. `environment/https` puts Caddy in front of it for TLS, giving `https://localhost`. In a deployment it is whatever host ignis is published on. Every path below is the same either way. + The base URL depends on the environment in use. `environment/http` publishes the app directly, giving `http://localhost:8088`. `environment/https` puts Caddy in front of it for TLS, giving `https://localhost`. In a deployment it is whatever host ignis is published on. Every path below is the same either way. ## Call sequence @@ -20,25 +24,28 @@ ignis carries no credential and asks for none. Every endpoint is reachable by an | 2 | `GET /api/v1/data/{code}` | Every physical input behind that archetype (roughly 200 fields): envelope areas, U-values, climate data, solar gains. `GET /api/v1/fields` gives a plain-language description of each field. | | 3 | `POST /api/v1/calculate/{code}` | Annual heating demand `q_h_nd` in kWh/(m²·a), from the ISO 13790 pipeline. | -Step 2 is optional. Call it when you need to inspect or report the inputs; `calculate` uses them either way. +Step 2 is optional, for inspecting or reporting 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. `year` must be a non-negative integer no later than the current calendar year. A year before the earliest or after the latest band defined for that country and type resolves to the oldest or newest period. `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. Step 2's response also carries the resolved variant's own band, at `tabula_data.BasicParameters.BuildingAppearance.Year1_Building` / `Year2_Building`. +A construction year can replace the period code: send `year=` instead of `period=` at step 1 and ignis resolves it. Exactly one of the two is required. `year` must be a non-negative integer no later than the current calendar year. A year before the earliest or after the latest band defined for that country and type resolves to the oldest or newest period. `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. Step 2's response also carries the resolved variant's own band, at `tabula_data.BasicParameters.BuildingAppearance.Year1_Building` / `Year2_Building`. ## 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. +`calculate` accepts an optional JSON body. Every field is independent and optional: send only the fields that change, and every other input comes from the archetype's TABULA defaults. -| You want to change | Field(s) | +| To change | Field(s) | |---|---| | The building's actual floor area | `A_ref` | | How cold or mild the local climate is | `HeatingDays`, `Theta_e`, `theta_i` | | How much sun the site gets | `I_Sol_South`, `I_Sol_East`, `I_Sol_West`, `I_Sol_North`, `I_Sol_Hor` | | Extra heat loss at wall, roof and window junctions | `delta_U_ThermalBridging_Original`, `delta_U_ThermalBridging_Refurbished` | -| The building's actual room height or storey count | `h_room`, `n_Storey` | +| The building's actual room height | `h_room` | | How airtight the building is, or how much it is ventilated | `n_air_infiltration`, `n_air_use` | | How much heat the building's structure can store | `c_m` | -See `CalculateRequest` in the reference below for exact types and validation rules; `HeatingDays` and the solar-irradiance fields must not be negative. +`CalculateRequest` in the reference below carries the exact types and validation rules. `HeatingDays` and the solar-irradiance fields must not be negative. + +!!! warning "`n_Storey` is accepted but has no effect" + `n_Storey` reaches the result only through the envelope-area estimation path, which applies when a variant's `Code_TypeIntake_EnvelopeArea` is `Estimation`. No shipped TABULA variant carries that code. Its other consumers are the envelope plausibility ratios, and those end in `Check_EnvArea_ExactToEstim`, which nothing reads. The value is validated and then changes no result. `h_room` is a real lever and is unaffected by this. !!! example "Overriding a single field" ```bash @@ -52,8 +59,8 @@ The body also accepts an optional `surfaces` list, one entry per physical elemen - Each entry takes an `id`, a `type` (`wall`, `window`, `roof`, `floor`, `door`), an `area` in m², a `u_value` in W/(m²·K), and, for windows, an `azimuth` and a `tilt`. - ignis merges the entries in each category into the single area and area-weighted U-value the calculation needs. -- A category you do not list keeps the archetype's default for that category. -- You own the accuracy of the geometry you send. ignis uses the areas, U-values and orientations as given and does not check them against the archetype. +- A category that is not listed keeps the archetype's default. +- The caller owns the accuracy of the geometry. ignis uses the areas, U-values and orientations as given and does not check them against the archetype. - `azimuth` is degrees clockwise from North (0 North, 90 East, 180 South, 270 West). For windows it also selects which direction's solar irradiance the window counts toward, rounded to the nearest of North, East, South or West. A window with no `azimuth` is assumed to face South. - `tilt` is degrees from horizontal: 0 is a flat skylight, 90 a vertical window. A window within 5 degrees of horizontal counts toward horizontal irradiance rather than a compass direction. city2tabula measures tilt the other way round (0 for a vertical wall, 90 for a flat roof), so convert its values with `ignis_tilt = 90 - c2t_tilt` before sending them. @@ -78,9 +85,9 @@ The body also accepts an optional `surfaces` list, one entry per physical elemen **Step 1:** Start the stack, from `environment/http/`: `docker compose -f docker-compose.prod.yml up -d`. On a first run, load the TABULA data once: `docker compose -f docker-compose.prod.yml --profile seed run --rm build-db`. -**Step 2:** Serve `docs/openapi/` on `http://localhost:8000` (`python -m http.server 8000` from that directory works), since `ALLOWED_ORIGINS` allows that origin already. Opening the file directly (`file://`) works for reading the reference, but **Try it out** needs an allowed origin. +**Step 2:** Serve `docs/openapi/` on `http://localhost:8000` (`python -m http.server 8000` from that directory), which `ALLOWED_ORIGINS` already allows. Opening the file directly (`file://`) is enough for reading the reference, but **Try it out** needs an allowed origin. **Step 3:** Pick `http://localhost:8088` from the **Servers** dropdown, then expand an endpoint, click **Try it out**, fill in the parameters, and **Execute**. !!! info "Using the HTTPS environment instead" - `environment/https` serves the same API on `https://localhost`. Select that server in the dropdown, and on the quickstart file open `https://localhost` in a tab once and accept the certificate warning first: browser JavaScript cannot click through it the way a manual page load can. \ No newline at end of file + `environment/https` serves the same API on `https://localhost`. Select that server in the dropdown. On the quickstart path, open `https://localhost` in a tab once and accept the certificate warning first: browser JavaScript cannot click through it the way a manual page load can. \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md index 014794a..a66beaf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,10 +1,14 @@ +--- +audience: developer +--- + # Getting started ignis is an HTTP API that computes TABULA heating demand for building archetypes. It ships as a Docker Compose stack: the Go application and its own PostgreSQL instance, optionally behind a Caddy reverse proxy that terminates TLS. ## Choosing an environment -`environment/` holds two self-contained directories. Pick one, `cd` into it, and everything you need is there. +`environment/` holds two self-contained directories. Each one is complete on its own. | Directory | Containers | Use when | |---|---|---| @@ -19,12 +23,12 @@ Each directory holds one compose file per source of images: |---|---| | `docker-compose.yml` | built from this checkout, so it picks up local code changes | | `docker-compose.prod.yml` | pulled from GHCR, no Go toolchain or source tree needed | -| `docker-compose.quickstart.yml` (`https` only) | pulled from GHCR, with Caddy's CA in a Docker volume instead of your trust store | +| `docker-compose.quickstart.yml` (`https` only) | pulled from GHCR, with Caddy's CA in a Docker volume instead of the host trust store | A third path, [manual setup](#manual-setup), runs the binaries without containers. !!! info "Both environments can run at once" - They use separate Compose projects (`ignis-http`, `ignis-https`), separate generated container names and separate database volumes, so both can be up together and you connect to whichever you need. Each has its own database and so needs its own seeding. + They use separate Compose projects (`ignis-http`, `ignis-https`), separate generated container names and separate database volumes, so both can run at once. Each has its own database and so needs its own seeding. !!! warning "Upgrading from a checkout made before the project rename" The Compose project names are now `ignis-http` and `ignis-https`, previously `building-simulation` for both, and the fixed container names are gone. An existing stack has to come down first, because the old containers still hold those names. Remove them by name, which reaches nothing but ignis: @@ -34,9 +38,9 @@ A third path, [manual setup](#manual-setup), runs the binaries without container docker rm ignis-app ignis-db ignis-reverse-proxy ``` - Do not use `docker compose -p building-simulation down`. That targets the project rather than this repository, so on a machine where another service still declares the old project name, it removes that service's containers as well, naming neither. + Do not use `docker compose -p building-simulation down`. That targets the project rather than this repository, so on a machine where another service still declares the old project name it removes that service's containers as well, naming neither. - Each environment now has its own database volume, so seed each one once after starting it. The previous shared volume, `building-simulation_ignis-db-data`, is left in place and no longer mounted; remove it with `docker volume rm building-simulation_ignis-db-data` once you are satisfied the new ones are populated. + Each environment now has its own database volume, so seed each one once after starting it. The previous shared volume, `building-simulation_ignis-db-data`, is left in place and no longer mounted; remove it with `docker volume rm building-simulation_ignis-db-data` once the new ones are populated. If `tentacron-net` already exists from `make tentacron-stack`, `up` fails with a label mismatch, because a network made by `docker network create` carries no Compose labels. Remove it with `docker network rm tentacron-net` while nothing is attached, and the first stack up recreates it correctly. @@ -117,7 +121,7 @@ How `https://localhost` behaves depends on where Caddy's local CA is stored. | Compose file | CA location | Result | |---|---|---| -| `docker-compose.quickstart.yml` | Docker-managed volume | Never enters your trust store. Expect an untrusted-certificate warning. | +| `docker-compose.quickstart.yml` | Docker-managed volume | Never enters the host trust store. Expect an untrusted-certificate warning. | | `docker-compose.yml`, `docker-compose.prod.yml` | Host directory (`CADDY_DATA_DIR`), created by `caddy trust` | Trusted, no warning, no `-k` needed. | On the quickstart path, click through the warning in the browser, or pass `-k` (curl), `--no-check-certificate` (wget), or "disable SSL verification" (Postman). @@ -135,8 +139,8 @@ cd ignis/environment/https docker compose -f docker-compose.quickstart.yml up -d ``` -!!! warning "Docker Desktop: work from a directory under your home folder" - Docker Desktop only shares paths under your home directory, or another folder added under File Sharing, into its VM. A working directory under `/tmp` fails with a bind-mount error like "not shared from the host", which does not obviously point at the File Sharing setting. Clone or copy these files under your home directory instead. +!!! warning "Docker Desktop: work from a directory under the home folder" + Docker Desktop only shares paths under the user's home directory, or another folder added under File Sharing, into its VM. A working directory under `/tmp` fails with a bind-mount error such as "not shared from the host", which does not obviously point at the File Sharing setting. Clone or copy these files under the home directory instead. This starts `db`, then `ignis` once the database reports healthy, then `proxy` once the app reports healthy. Only the proxy publishes a host port (`HOST_HTTPS_PORT`, default `443`). @@ -144,9 +148,9 @@ Then [Seeding the database](#seeding-the-database) and [Verifying](#verifying). ### From source, with a trusted certificate -Builds `ignis` and `build-db` from this checkout, so it picks up local code changes, and reuses a CA your browser already trusts. +Builds `ignis` and `build-db` from this checkout, so it picks up local code changes, and reuses a CA the browser already trusts. -Install the `caddy` CLI on the host and run `caddy trust` once. That installs a local CA into your OS and browser trust store, which the proxy then reuses. +Install the `caddy` CLI on the host and run `caddy trust` once. That installs a local CA into the OS and browser trust store, which the proxy then reuses. ```bash caddy trust @@ -189,14 +193,14 @@ The TABULA workbook is baked into the `build-db` image, so there is nothing to d exec db psql -U postgres -d ignis -c "\dt tabula.*" ``` -That lists the seeded tables. Then check the API answers, using the base URL for your environment: +That lists the seeded tables. Then check that the API answers, using the base URL of the environment in use: ```bash curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8088/ignis/health # environment/http curl -s -o /dev/null -w '%{http_code}\n' https://localhost/ignis/health # environment/https ``` -Both return `200`. Add `-k` on the https quickstart path, where the certificate does not chain to a CA you trust. +Both return `200`. Add `-k` on the https quickstart path, where the certificate does not chain to a trusted CA. ## Tearing down @@ -242,7 +246,7 @@ For local development without containers. cd ignis ``` - If you have already cloned without it, run `git lfs install && git lfs pull`. Verify with `ls -l data/tabula-calculator-lite.xlsx`, which should be roughly 11 MB rather than a few hundred bytes. + After a clone made without it, run `git lfs install && git lfs pull`. Verify with `ls -l data/tabula-calculator-lite.xlsx`, which should be roughly 11 MB rather than a few hundred bytes. ### Configuration @@ -250,13 +254,13 @@ This path reads `environment/http/env/app.env` for database settings and `enviro | Variable | Change to | |---|---| -| `DB_HOST` | `localhost`, or wherever your PostgreSQL instance runs | -| `DB_PASSWORD` | your own credential, no default | +| `DB_HOST` | `localhost`, or wherever the PostgreSQL instance runs | +| `DB_PASSWORD` | a real credential, no default | | `DB_SSL_MODE` | `disable` only for a local database on the same machine, `require` everywhere else | | `ALLOWED_ORIGINS` | the browser origins calling ignis directly. Leave unset for server-to-server calls, the intended deployment model | !!! warning "Bind it to localhost" - The binary listens on every interface. Nothing in ignis limits who may call it, so keep it off any interface you do not control. + The binary listens on every interface. Nothing in ignis limits who may call it, so keep it off any interface whose access is not controlled. ### Build and run @@ -275,7 +279,7 @@ This produces `bin/ignis`, `bin/build_db`, and `bin/validate`. ### Validate -Runs the full 17-level TABULA calculation pipeline against every row in the database and checks that each result stays within 2% of the reference value from the workbook. +Runs the full 17-level TABULA calculation pipeline against every row in the database and checks that each result stays within 2.5% of the reference value from the workbook. ```bash ./bin/validate @@ -315,7 +319,7 @@ For `environment/http`: `HOST_BIND` and `HOST_PORT` default to `127.0.0.1` and ` For `environment/https`: `CADDY_DATA_DIR` is required; `APP_PORT` and `HOST_HTTPS_PORT` default to `8080` and `443`. -Set `IGNIS_IMAGE_TAG` to pin a release, which is strongly advised for anything you do not want moving underneath you. See [Pinning a version](#pinning-a-version). +Set `IGNIS_IMAGE_TAG` to pin a release. This is strongly advised for any deployment that should not move on the next publish. See [Pinning a version](#pinning-a-version). ### 3. Set the site address (https only) diff --git a/docs/index.md b/docs/index.md index 487329e..2ac6e95 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,7 @@ +--- +audience: developer +--- + ![Ignis logo](assets/logo/ignis-logo-dark.svg#gh-dark-mode-only) ![Ignis logo](assets/logo/ignis-logo-light.svg#gh-light-mode-only) diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 510769e..f36ce06 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -258,7 +258,7 @@ components: description: > 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 + 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) @@ -509,7 +509,7 @@ components: format: double description: > Overrides BuildingAppearance.H_room (m), the archetype's assumed room - height. Feeds the ventilation heat transfer coefficient directly — + height. Feeds the ventilation heat transfer coefficient directly, often a large lever, since TABULA's generic default is frequently a poor match for a specific real building. Must be greater than 0. example: 2.8 @@ -517,9 +517,13 @@ components: type: integer description: > Overrides BuildingAppearance.N_Storey, the archetype's assumed storey - count. Feeds the envelope-area *estimation* path — has less effect - once real surfaces are also given, since those bypass estimation for - the categories they cover. Must be greater than 0. + count. Accepted and validated, but without effect on any shipped + TABULA variant: it reaches the result only through the envelope-area + estimation path, which applies when Code_TypeIntake_EnvelopeArea is + "Estimation", and no shipped variant carries that code. Its other + consumers are the envelope plausibility ratios, which end in + Check_EnvArea_ExactToEstim, and nothing reads that. + Must be greater than 0. example: 3 n_air_infiltration: type: number @@ -540,7 +544,7 @@ components: format: double description: > Overrides HeatTransfer.C_m (J/(m²K)), the internal heat capacity per m² - of useful floor area — feeds the gain-utilization time constant + of useful floor area. Feeds the gain-utilization time constant (calc_level_12.go). Must be greater than 0. example: 165000 surfaces: @@ -551,7 +555,7 @@ components: building's geometry has rather than a generic archetype. Surfaces are grouped by type and collapsed into an area-weighted equivalent (summed area, area-weighted average U-value) before - the pipeline runs — mathematically exact, since U-values are + the pipeline runs. This is mathematically exact, since U-values are conductances and conductances in parallel add weighted by area. A category with no surfaces given keeps its TABULA default. The caller owns the geometric accuracy of the areas, U-values diff --git a/docs/validation.md b/docs/validation.md index 9598485..ab99702 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,6 +1,10 @@ +--- +audience: developer +--- + # Validation -Pipeline validated against the TABULA reference values (±2% tolerance on `q_h_nd`) across 2,147 buildings in 20 European countries. Last run: November 2025. +The pipeline is validated against the TABULA reference values across 2,147 buildings in 20 European countries, with a tolerance of ±2.5% on `q_h_nd`. Last run: November 2025. --- @@ -8,26 +12,26 @@ Pipeline validated against the TABULA reference values (±2% tolerance on `q_h_n | Country | Buildings | Pass rate | |---|---|---| -| 🇦🇹 Austria | 165 | 100% ✓ | -| 🇧🇪 Belgium | 99 | 100% ✓ | -| 🇧🇬 Bulgaria | 78 | 100% ✓ | -| 🇨🇾 Cyprus | 36 | 100% ✓ | -| 🇨🇿 Czech Republic | 84 | 100% ✓ | -| 🇩🇰 Denmark | 91 | 100% ✓ | -| 🇫🇷 France | 120 | 100% ✓ | -| 🇩🇪 Germany | 232 | 100% ✓ | -| 🇬🇷 Greece | 144 | 100% ✓ | -| 🇭🇺 Hungary | 45 | 100% ✓ | -| 🇮🇪 Ireland | 118 | 100% ✓ | -| 🇮🇹 Italy | 106 | 100% ✓ | -| 🇳🇱 Netherlands | 135 | 100% ✓ | -| 🇳🇴 Norway | 69 | 100% ✓ | -| 🇵🇱 Poland | 78 | 100% ✓ | -| 🇷🇸 Serbia | 111 | 100% ✓ | -| 🇸🇮 Slovenia | 112 | 100% ✓ | -| 🇸🇪 Sweden | 171 | 100% ✓ | -| 🇬🇧 United Kingdom | 81 | 100% ✓ | -| 🇪🇸 Spain | 72 | 22.2% ⚠ | +| Austria | 165 | 100% | +| Belgium | 99 | 100% | +| Bulgaria | 78 | 100% | +| Cyprus | 36 | 100% | +| Czech Republic | 84 | 100% | +| Denmark | 91 | 100% | +| France | 120 | 100% | +| Germany | 232 | 100% | +| Greece | 144 | 100% | +| Hungary | 45 | 100% | +| Ireland | 118 | 100% | +| Italy | 106 | 100% | +| Netherlands | 135 | 100% | +| Norway | 69 | 100% | +| Poland | 78 | 100% | +| Serbia | 111 | 100% | +| Slovenia | 112 | 100% | +| Sweden | 171 | 100% | +| United Kingdom | 81 | 100% | +| Spain | 72 | 22.2% | **Overall: 97.4% (2,091 / 2,147 buildings passing)** @@ -35,32 +39,40 @@ Pipeline validated against the TABULA reference values (±2% tolerance on `q_h_n ## Spain: known issue -56 of 72 buildings fail (77.8%). The failures are systematic (calculated values consistently deviate from the TABULA reference), which points to a Spain-specific parameter rather than a general pipeline bug. +The failures are systematic: calculated values deviate from the TABULA reference in one direction, which points to a Spain-specific parameter rather than a general pipeline bug. **Status:** under investigation. -### Investigation checklist +### Measured 2026-09-23 + +A re-measurement of all 72 Spanish variants against TABULA defaults, taken after the table above. It was run through `POST /api/v1/calculate/{code}` with an empty request body, which reaches the same pipeline as `./bin/validate`: + +| Measure | Value | +|---|---| +| Passing | 48 of 72 | +| Signed mean error | -2.32% | +| Rows computing below the reference | 71 of 72, none above | +| Failure range | 2.50% to 3.57% | + +The offset is uniform rather than scattered: -2.32% for references below 5 kWh/(m²·a), -2.32% from 5 to 20, and -2.27% above 20. Germany, Italy and France show no such bias in a 25-row sample each. -- [ ] Compare climate zone parameters against Mediterranean-specific values -- [ ] Check construction type and attic/cellar condition code handling for Spanish archetypes -- [ ] Validate thermal bridge calculations against Spanish building standards -- [ ] Field-by-field comparison of a failing building against the TABULA Excel workbook -- [ ] Cross-reference with the Spanish national energy performance methodology (CTE DB-HE) +!!! warning "Spain's pass count is unstable by construction" + A country-wide offset of about 2.3% sits just inside the ±2.5% gate, so small differences move rows across the line. Stored references are rounded to one decimal, which at the lowest magnitudes here (references as low as 1.7) is worth up to 2.9% on its own. Any pass count published for Spain needs its measurement date attached. --- ## Validation methodology -- **Tolerance:** ±2% on `q_h_nd` (annual heating energy demand, kWh/(m²·a)) +- **Tolerance:** ±2.5% on `q_h_nd` (annual heating energy demand, kWh/(m²·a)) - **Reference:** TABULA Excel workbook (`data/tabula-calculator-lite.xlsx`) - **Pipeline:** 17-level cascading calculation: geometry, envelope, U-values, climate, solar gains, thermal bridges, heat transfer coefficients !!! note "Scope" This runs the pipeline directly against each database row's unmodified - TABULA defaults — it never goes through the `/api/v1/calculate/{code}` - HTTP handler, so it doesn't exercise that endpoint's optional overrides - (`A_ref`, `surfaces`, etc.). Those are additive: a request with no body - reaches the exact same code path this validation covers. + TABULA defaults. It never goes through the `/api/v1/calculate/{code}` + HTTP handler, so it does not exercise that endpoint's optional overrides + (`A_ref`, `surfaces` and the rest). Those are additive: a request with no + body reaches the same code path this validation covers. --- diff --git a/documentation/content/08-crosscutting-concepts.md b/documentation/content/08-crosscutting-concepts.md index 6e936ef..4a11f26 100644 --- a/documentation/content/08-crosscutting-concepts.md +++ b/documentation/content/08-crosscutting-concepts.md @@ -4,11 +4,11 @@ ignis's code has no TLS, no certificates, no HTTPS server. That makes TLS a property of a deployment rather than of the service, and the two Compose environments are the two answers: `environment/https` puts Caddy in front to hold the certificate and decrypt, `environment/http` leaves it to whatever already terminates TLS upstream. -Where Caddy is used, the hop from proxy to app is plain HTTP, so it is only as safe as the network it crosses. That is fine while the network is one we control (a single Docker network). It stops being fine if that hop ever crosses infrastructure we don't control, which is why mutual TLS on that hop is listed as future work. +Where Caddy is used, the hop from proxy to app is plain HTTP, so it is only as safe as the network it crosses. That is acceptable while the network is a controlled one, such as a single Docker network. It stops being acceptable once that hop crosses infrastructure outside that control, which is why mutual TLS on the hop is listed as future work. ## Trust follows the CA, not the certificate -A client doesn't trust a certificate on its own. It trusts the certificate authority (CA) that signed it, and that trust then covers every certificate the same CA signs. This is why per-service proxies scale: sign each one's certificate with the same CA (a public CA for internet-facing services, a shared internal CA for service-to-service), and a client establishes trust once, not once per service. +A client does not trust a certificate on its own. It trusts the certificate authority (CA) that signed it, and that trust then covers every certificate the same CA signs. This is why per-service proxies scale: sign each one's certificate with the same CA (a public CA for internet-facing services, a shared internal CA for service-to-service), and a client establishes trust once, not once per service. Locally this is proven directly: `caddy trust` trusts the local CA once, and every certificate it signs afterward is trusted, including from a freshly recreated container. @@ -26,6 +26,6 @@ This is enforced by the browser on behalf of the page it loaded. A caller that i ## Open items -- Mutual TLS on the proxy-to-app hop, for deployments where that hop leaves infrastructure we control. +- Mutual TLS on the proxy-to-app hop, for deployments where that hop leaves controlled infrastructure. - A caller credential, should ignis ever be deployed somewhere the network is not the trust boundary. diff --git a/documentation/content/09-architectural-decisions.md b/documentation/content/09-architectural-decisions.md index a6e748b..d4938e0 100644 --- a/documentation/content/09-architectural-decisions.md +++ b/documentation/content/09-architectural-decisions.md @@ -22,7 +22,7 @@ **Decision:** They run as separate containers. -**Reason:** Docker expects one process per container. Two processes under a wrapper script lose supervision: if the app crashed, Docker wouldn't notice, since the script keeps running. Separate containers also let the proxy be reloaded or recreated without touching the app, which we relied on repeatedly during this work. +**Reason:** Docker expects one process per container. Two processes under a wrapper script lose supervision: if the app crashes, Docker does not notice, because the script keeps running. Separate containers also let the proxy be reloaded or recreated without touching the app. **Rejected:** One container with a process supervisor (e.g. `supervisord`). Extra complexity for nothing this deployment needs. diff --git a/internal/api/handler/calculation.go b/internal/api/handler/calculation.go index 9053d61..40068cf 100644 --- a/internal/api/handler/calculation.go +++ b/internal/api/handler/calculation.go @@ -63,9 +63,11 @@ func init() { // so this is often a large lever: TABULA's generic default is frequently a poor // match for a specific real building (a lecture hall or workshop can easily run // 4-5m against a 2.5m archetype default). n_Storey overrides the assumed storey -// count (BuildingAppearance.N_Storey), which feeds the envelope-area *estimation* -// path (calc_level_02.go/03.go/06.go) — has less effect once real surfaces are -// also given, since those bypass estimation for the categories they cover. +// count (BuildingAppearance.N_Storey). It is validated but changes no result: +// it reaches the output only through the envelope-area estimation path, gated on +// Code_TypeIntake_EnvelopeArea == "Estimation" (calc_level_05.go/06.go/07.go), +// which no shipped TABULA variant selects, and its other consumers are the +// plausibility ratios ending in Check_EnvArea_ExactToEstim, which nothing reads. // // surfaces replaces TABULA's fixed 2-3 slots per element category with an // arbitrary list of individual physical surfaces (as a real building's