Compare commits

...
10 Commits
Author SHA1 Message Date
Nasim SadatandClaude Opus 5.5 89b1709fd9 Keycloak theme for the sign-in and sign-out pages (PD-23, PD-26)
Realm petrolyst's login theme, on Keycloak's unstyled base: a slate panel with the logo and its line
beside the form on a soft card; on a phone a slate band above it; light and dark. Satoshi stays out
of Git by its licence (build.sh). The README adds the install steps. Not deployed.

Bands: none (identity theme).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 01:29:57 +01:00
Nasim SadatandClaude Opus 5.5 d95e6e184a Superset wears the new design; site charts sit as boxes (PD-24 to PD-26)
Theme tokens for the slate surfaces and borders, the logo with the green Y, chart boxes at 16 px
with the card shadow. Full figures with the unit in the subheader; every layer of the tabs
transparent, so the site's charts sit as boxes in Firefox and Safari too. The Overview gets a Sites
filter and the period's scope returns to the time charts (PD-25). The embedded frame can shrink again.
A change to the image's inputs: Superset rebuilds on the next deploy.

Bands: Superset. Rules: 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 01:29:57 +01:00
Nasim SadatandClaude Opus 5.5 78a498f1ce Sign-in by customer: realm petrolyst, row rules, guest role (PD-21)
Keycloak import for realm petrolyst: two clients, petrolyst with MFA for
Superset and Traefik, petrolyst-app password only. It lists only
app.petrolyst.com and traefik.petrolyst.com, and no customer names, as this
repository is public. public.v_operator shows each customer's group. Dashboards
overview and site, role Petrolyst Guest, filtered by row. Superset refuses a
user with no mapped role. The realm travels with the release: each release's
compose files name their realm and secret file, and a rollback needs only its
target's secret. Not deployed.

Checks earlier on this content: tests/run.sh 109 of 109 and read-only, dashboards
import twice, column drift 0, compose config on old and new trees, the secret
check on a scratch folder, a rehearsal on a throwaway Keycloak 26.4. Never a real
deploy or rollback; shellcheck not installed today, clean when last run. Codex
inspected once; its fixes are not re-inspected, on the owner's word.

Bands: persistence, Superset. Rules: 1, 2, 3, 6, 14.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 18:31:14 +01:00
Nasim SadatandClaude Opus 5.5 ab553526f6 fuel-db: customer on every row, place and holder for site, licence tables
Draft migrations rewritten in place (PD-16, PD-18), with 0004 for the licence
tables (PD-20) and rule tests in fuel-db/tests. Dashboards' column list
regenerated; coverage_detail selects operator_uid.

Checks: tests/run.sh 106 of 106; 18 views, 686 rows, 0 differences against a
reference from the same corpus. Codex's fixes to its inspection are not
re-inspected.

Bands: persistence, Superset. Rules: 6, 8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:40:16 +01:00
Nasim SadatandClaude Opus 5.5 e4806f5799 Trino out of the code; commit messages name bands and rules
The rename step in init.sh and the guard in deploy/lib.sh are gone. README's
procedure is now "Removing the old project"; it keeps the old names until it
has run on the server. Migrations stay here (open point 8), and their README
says so. AGENTS.md adds the commit-message rule. Not deployed: live still runs
Trino.

Bands: 4 (Superset), persistence (docs). Rules: 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:50:57 +01:00
Nasim SadatandClaude Opus 5.5 8405706ed8 Remove Trino: Superset reads fuel-db directly as fuel_ro (PD-14)
trino/ deleted; the role trino_ro becomes fuel_ro, its password read from a
secret file at connect time; dashboards' SQL and column list for PostgreSQL;
the Compose project is renamed petrolyst, with volumes pinned to their old
names. AGENTS.md points to the architecture context. Not deployed: the next
deploy needs README's one-time steps first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:57:18 +01:00
Nasim SadatandClaude Opus 5.5 686318dfac Remove the Keycloak client import
The file was already gone from the working tree; why is not recorded. README
and AGENTS.md still point to it. Revert this commit to bring it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:57:18 +01:00
Nasim SadatandClaude Opus 5.5 c92c960025 Agent rules for the repository (AGENTS.md, CLAUDE.md)
Layout, deploy and dashboard rules, and checks. Correction to da3f803: the
shell lint did run afterwards, through the README's Docker shellcheck command
(koalaman/shellcheck:v0.10.0 -S warning): clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:17:07 +01:00
Nasim SadatandClaude Opus 5.5 da3f803947 Retire the Superset reader setup and the ATOMA dashboard (PD-13)
ATOMA's users use the Petrolyst app; Superset keeps admins, editors and the
app's embedded dashboards. Removed: the "ATOMA Reader" role and its rules
(petrolyst_reader.py, ReaderAccessMixin, reader.css, the head template, the
SQL-hiding patch), the atoma-reader mapping (it now maps to nothing in
Superset), the iframe allowance for the Map tab, and the "atoma" dashboard
with its Fleet/Map tab, D0 and dashboard.css. The bundle holds atoma-overview
and atoma-site only (byte-identical to before); the guest role keeps the
reader's 12 permissions in roles/atoma_guest.py. Brand files now name
petrolyst-web. SPEC marks the retired parts as history.

Checks (local): bundle checks pass; image rebuilt and healthy; import with
column drift 0 and unchanged embedding ids; the production config loads with
OAuth, embedding off, no reader hook; admin sees atoma-site with its tabs and
filter column; the app's browser tests pass 10/10. shellcheck not run (not
installed). Not deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:10:19 +01:00
Nasim SadatandClaude Opus 5.5 1b8e57adb4 Trial state of 29 Oct 2026: ATOMA reader setup, app dashboards, embedding notes
Baseline before the clean-up (PD-13): the PD-11 reader setup and the PD-12 app
dashboards as they ran on the local stack, uncommitted until now. Not deployed,
not independently reviewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 20:50:26 +01:00
56 changed files with 4788 additions and 778 deletions

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
# petrolyst-infra: agent rules
Deployment of the petrolyst server (atoma.petrolyst.com) and the Superset customization.
Runbook: README.md. Workspace rules: `../AGENTS.md`; shared context: `../.context/` (server and
identity facts: `deployment.md`).
**Architecture:** read `../.context/architecture.md` before planning or building. This repository
runs persistence (fuel-db and its migrations) and Superset; name the bands a change touches and
keep the rules there; a change against a rule or an open conflict goes to the owner first.
## Layout
- `stacks/`, `traefik/`, `deploy/`, `host/`: how and where things run.
- `petrolyst-insights/`: what Superset looks like and does (config, security manager, the guest
role, branding, patches, dashboards as code). Its README maps each file to the image.
- `keycloak/`: the import of realm `petrolyst`'s two clients (`petrolyst`, `petrolyst-app`).
## Rules
- **Commit messages** end their body with the bands and rules touched, for example
`Bands: 4 (server). Rules: 2, 3.`, or `Bands: none (docs).` for text only (architecture rule 1).
- **Deploys:** only by README "Manual deploy", only when the owner asks. There is no CI. Commit on
`main` first; never push or deploy unprompted.
- **No secrets** in the repository; `deploy/deploy.sh` refuses if one appears.
- **The Superset image's inputs** (`SUPERSET_INPUTS` in `deploy/lib.sh`) make its tag: a change
there rebuilds Superset on the next deploy.
- **Dashboards are code:** change `build_bundle.py` and SPEC.md, never the UI followed by an
export. `import_bundle.py` never deletes; old objects go by hand (`dashboards/README.md`).
- **Embedding** stays off in production (`superset_config.py`) until PD-12's points are decided;
the local stack turns it on in its own override.
- **Customers' users** have no Superset account (PD-13, PD-21): role `reader` of client `petrolyst-app`
opens the Petrolyst app only, for the customer of their Keycloak group; Superset refuses them.
- fuel-db migrations (`stacks/fuel-monitoring/fuel-db/migrations/`) are applied by hand (P3).
## Checks
- Shell: the README's Docker shellcheck command (warnings and errors must be zero).
- Dashboards: `build_bundle.py` checks the bundle as it builds; import locally and expect column
drift 0 (`../.context/local-stack.md`).
- Superset changes: rebuild the local image and check it turns healthy before calling it done.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+119 -32
View File
@@ -5,7 +5,8 @@ Everything that runs on the petrolyst server (109.199.127.84, Ubuntu 24.04):
- **Traefik** at the edge for every project on the server. Its dashboard is at - **Traefik** at the edge for every project on the server. Its dashboard is at
https://traefik.petrolyst.com, behind Keycloak. https://traefik.petrolyst.com, behind Keycloak.
- **fuel-monitoring** (ATOMA): Superset at https://atoma.petrolyst.com, reading the operational - **fuel-monitoring** (ATOMA): Superset at https://atoma.petrolyst.com, reading the operational
store `fuel-db` (Postgres 17 with TimescaleDB) through Trino. Sign-in goes through Keycloak store `fuel-db` (Postgres 17 with TimescaleDB) directly, as the read-only role `fuel_ro`
(no Trino since 29 Oct 2026). Sign-in goes through Keycloak
client `petrolyst`, and the UI carries the Petrolyst brand. client `petrolyst`, and the UI carries the Petrolyst brand.
**Deploys are manual.** A person deploys a clean commit of `main` from the Mac ("Manual deploy" **Deploys are manual.** A person deploys a clean commit of `main` from the Mac ("Manual deploy"
@@ -17,10 +18,10 @@ Customization and deployment are separate, as with media-insights and media-inte
|---|---| |---|---|
| `petrolyst-insights/` | how Superset looks and behaves for Petrolyst: config, Keycloak login code, branding, patches, dashboards. See its README. | | `petrolyst-insights/` | how Superset looks and behaves for Petrolyst: config, Keycloak login code, branding, patches, dashboards. See its README. |
| `traefik/` | edge proxy, TLS, the `sso` middleware (oauth2-proxy) that guards the dashboard | | `traefik/` | edge proxy, TLS, the `sso` middleware (oauth2-proxy) that guards the dashboard |
| `stacks/fuel-monitoring/` | Compose stack, Superset Dockerfile, Trino config and catalog, fuel-db roles | | `stacks/fuel-monitoring/` | Compose stack, Superset Dockerfile and init, fuel-db roles and migrations |
| `deploy/` | `deploy.sh` (run on the server by a manual deploy), `rollback.sh` (manual), `lib.sh` (shared) | | `deploy/` | `deploy.sh` (run on the server by a manual deploy), `rollback.sh` (manual), `lib.sh` (shared) |
| `host/` | one-time server setup: `bootstrap.sh`, `secrets-init.sh` | | `host/` | one-time server setup: `bootstrap.sh`, `secrets-init.sh` |
| `keycloak/` | the `petrolyst` client for Keycloak's partial import | | `keycloak/` | the clients `petrolyst` and `petrolyst-app` for Keycloak's partial import |
No credential is in this repository. Secrets exist only on the server, under No credential is in this repository. Secrets exist only on the server, under
`/opt/petrolyst/secrets/`. A deploy needs only root SSH access to the server (`ssh petrolyst`). `/opt/petrolyst/secrets/`. A deploy needs only root SSH access to the server (`ssh petrolyst`).
@@ -38,10 +39,10 @@ No credential is in this repository. Secrets exist only on the server, under
4. It resolves the artifacts and writes them to `releases/<sha>/ARTIFACTS`: 4. It resolves the artifacts and writes them to `releases/<sha>/ARTIFACTS`:
- `SUPERSET_IMAGE_TAG` is a hash of exactly the Superset image's build inputs. They include - `SUPERSET_IMAGE_TAG` is a hash of exactly the Superset image's build inputs. They include
`superset/init.sh`, so changing that file runs `superset-init` again. `superset/init.sh`, so changing that file runs `superset-init` again.
- `TRINO_CONFIG_HASH` and `TRAEFIK_CONFIG_HASH` are hashes of the bind-mounted config. - `TRAEFIK_CONFIG_HASH` is a hash of Traefik's bind-mounted static config.
- Compose recreates a container only when its value changes. - Compose recreates a container only when its value changes.
5. Preflight, before anything live changes: 5. Preflight, before anything live changes:
- every secret and env file exists; - every secret file exists;
- `petrolyst-insights/` holds no deployment files; - `petrolyst-insights/` holds no deployment files;
- `docker compose config` passes for every stack; - `docker compose config` passes for every stack;
- the Superset image is built if it is new; - the Superset image is built if it is new;
@@ -52,13 +53,14 @@ No credential is in this repository. Secrets exist only on the server, under
- An empty database is a fresh install: it runs `superset-init`. - An empty database is a fresh install: it runs `superset-init`.
- If the image changed, or the database is not at the image's migration head: it stops - If the image changed, or the database is not at the image's migration head: it stops
`superset`, writes `backups/superset-meta_<old tag>_<revision>_<time>.dump`, then runs `superset`, writes `backups/superset-meta_<old tag>_<revision>_<time>.dump`, then runs
`superset-init` (`db upgrade`, `init`, registers "Fuel (Trino)"). `superset-init` (`db upgrade`, `init`, registers "Fuel": fuel-db as `fuel_ro`, its password
read from `/run/secrets` at connect time, never stored in Superset).
- If the image is unchanged and the schema is at its head, it does none of this. - If the image is unchanged and the schema is at its head, it does none of this.
8. Smoke tests. A failure ends the deploy with an error and prints the stacks' state. 8. Smoke tests. A failure ends the deploy with an error and prints the stacks' state.
- `/health` answers through Traefik with a valid certificate. - `/health` answers through Traefik with a valid certificate.
- `/login/` leads to Keycloak with `client_id=petrolyst` and PKCE. - `/login/` leads to Keycloak with `client_id=petrolyst` and PKCE.
- The dashboard redirects to Keycloak. - The dashboard redirects to Keycloak.
- Trino reads `fuel`, and Superset reaches Trino. - Superset's "Fuel" connection reads fuel-db as `fuel_ro`, and its transactions are read-only.
9. Retention: 9. Retention:
- 5 releases; - 5 releases;
- backups for 30 days; - backups for 30 days;
@@ -75,7 +77,6 @@ The manual deploy then checks both hosts from outside.
| `/opt/petrolyst/incoming/<sha>-manual-<time>` | deploy | one upload; only `deploy.sh` turns it into a release | | `/opt/petrolyst/incoming/<sha>-manual-<time>` | deploy | one upload; only `deploy.sh` turns it into a release |
| `/opt/petrolyst/releases/<sha>` | deploy, read-only | finalized release plus `ARTIFACTS`; the last 5 are kept | | `/opt/petrolyst/releases/<sha>` | deploy, read-only | finalized release plus `ARTIFACTS`; the last 5 are kept |
| `/opt/petrolyst/secrets/{shared,traefik,fuel-monitoring}/` | root:deploy 0750, files 0444 | secrets, mounted as `/run/secrets/<name>` | | `/opt/petrolyst/secrets/{shared,traefik,fuel-monitoring}/` | root:deploy 0750, files 0444 | secrets, mounted as `/run/secrets/<name>` |
| `/opt/petrolyst/env/fuel-monitoring.env` | root:deploy 0440 | `FUEL_DB_TRINO_PASSWORD` for Trino's `${ENV:…}` |
| `/opt/petrolyst/backups/` | deploy 0750 | Superset metadata dumps | | `/opt/petrolyst/backups/` | deploy 0750 | Superset metadata dumps |
| `/opt/petrolyst/state/` | deploy | last deployed SHA and Superset image tag; `rollback-in-progress` during a migration rollback | | `/opt/petrolyst/state/` | deploy | last deployed SHA and Superset image tag; `rollback-in-progress` during a migration rollback |
@@ -87,15 +88,23 @@ Create the private repository `Petrolyst/petrolyst-infra` and push this reposito
it. Gitea only keeps the history: deploys are manual, so it needs no runner, no Actions and no it. Gitea only keeps the history: deploys are manual, so it needs no runner, no Actions and no
deploy secrets. deploy secrets.
### 2. Keycloak (realm `thedigitalbridge` on iam.thedigitalbridge.io) ### 2. Keycloak (realm `petrolyst` on iam.thedigitalbridge.io)
1. Realm settings → Action → **Partial import**: choose `keycloak/petrolyst-partial-import.json`, 1. Realm settings → Action → **Partial import**: choose `keycloak/petrolyst-realm-import.json`,
and "Skip" if a resource exists. This creates: and "Skip" if a resource exists. This creates:
- the confidential client `petrolyst`: standard flow only, PKCE S256, redirect URIs for both - the confidential client `petrolyst`, for Superset and Traefik: standard flow only, PKCE S256,
hosts; redirect URIs for both hosts;
- its client roles `superset-admin`, `superset-editor`, `superset-viewer` and `traefik-admin`; - its client roles `superset-admin`, `superset-editor`, `superset-viewer` and `traefik-admin`;
- a mapper that puts those roles into the claim `petrolyst_roles`. - a mapper that puts those roles into the claim `petrolyst_roles`;
- the confidential client `petrolyst-app`, for the Petrolyst app, with its role `reader` and two
mappers: `petrolyst_roles`, and the person's groups with their full path in `petrolyst_groups`,
both in the ID token. Both clients' addresses are on app.petrolyst.com, where everything moves
(PD-16, PD-21); atoma.petrolyst.com is going away and is not in the import.
2. Clients → petrolyst → Credentials: copy the client secret (step 3 stores it on the server). 2. Clients → petrolyst → Credentials: copy the client secret (step 3 stores it on the server).
**Realm settings:** registration off; brute-force detection on; a password policy of at least 12
characters, because a password is the only lock for customers' users; SSO session idle 8 hours,
max 10 hours, because the app's session is renewed through Keycloak about hourly and the default
idle limit of 30 minutes would ask for a password each time.
3. **MFA**, which Exhibit B requires. A copy of the built-in `browser` flow is not enough, for two 3. **MFA**, which Exhibit B requires. A copy of the built-in `browser` flow is not enough, for two
reasons: its Cookie step accepts an existing password-only session from another client of the reasons: its Cookie step accepts an existing password-only session from another client of the
realm, and its conditional OTP subflow skips users who have not set up OTP. Instead: realm, and its conditional OTP subflow skips users who have not set up OTP. Instead:
@@ -105,14 +114,29 @@ deploy secrets.
- Clients → petrolyst → Advanced → Authentication flow overrides → Browser flow: - Clients → petrolyst → Advanced → Authentication flow overrides → Browser flow:
`petrolyst-mfa`. `petrolyst-mfa`.
Result: every sign-in to a Petrolyst app asks for password and OTP, and a user without OTP Result: every sign-in to Superset or Traefik asks for password and OTP, and a user without OTP
enrols first. Other clients of the realm are unaffected. There is no silent SSO between enrols first. There is no silent SSO between them. Client `petrolyst-app` keeps the realm's own
Superset and the dashboard. flow: customers' users sign in with a password only, the owner's choice "for now" (PD-21).
4. Give people roles: Users → (user) → Role mapping → Assign role → filter by clients → 4. **A customer is a top-level group.** Groups → Create group, named as the customer's
`petrolyst`. Group mappings work too. `keycloak_group` in fuel-db (without the slash), then Role mapping → Assign role → filter by
clients → `petrolyst-app` `reader`. Adding a person to the group is then the only step, and they
see that customer in the app and no other. Exactly one customer per person: two give "No access".
They need no role on client `petrolyst`, and Superset refuses them. A person in no customer's
group sees "No access"; that includes our own staff, who join a customer's group to see it in
the app.
**Administrators:** Users → (user) → Role mapping → Assign role → filter by clients → `petrolyst`.
5. Users of the Traefik dashboard need **Email verified** switched on, because oauth2-proxy 5. Users of the Traefik dashboard need **Email verified** switched on, because oauth2-proxy
refuses unverified addresses. refuses unverified addresses.
6. **Branding (optional):** the sign-in and sign-out pages, theme `petrolyst` in `keycloak/themes/`, for the whole realm, both clients.
- Build on the Mac: `keycloak/themes/build.sh <folder with Satoshi-Variable.woff2>`, normally `petrolyst-platform/web/src/fonts/satoshi`.
The font goes only into `dist/petrolyst-theme.jar`, never into Git, because its licence forbids distributing it: so build it yourself and do not hand the file to outsiders.
- Install on Keycloak's server, either way. **No restart:** put the jar's `theme/petrolyst/` folder into Keycloak's `themes/` folder, readable by Keycloak's
user. Keycloak listed and used it without a restart on a test 26.4 with theme caching on; not tried on the real one. **With a restart,** which drops sign-in for every
realm for a moment: the jar into `providers/`, and `kc.sh build` if it runs an optimised build.
- Realm `petrolyst` → Realm settings → Themes → Login theme → `petrolyst` → Save. Other realms keep their look. To undo, set it back.
- `keycloak/themes/check-brand.sh` says whether the theme's copies of the brand tokens still match `Petrolyst/design/`.
### 3. Server (as root, `ssh petrolyst`) ### 3. Server (as root, `ssh petrolyst`)
The server needs only the two host scripts; it never gets Gitea credentials. The server needs only the two host scripts; it never gets Gitea credentials.
@@ -153,13 +177,13 @@ becomes the first Superset admin; there is no local admin account.
| Task | How | | Task | How |
|---|---| |---|---|
| Deploy | "Manual deploy" below, from a clean `main` | | Deploy | "Manual deploy" below, from a clean `main` |
| Re-deploy a kept release | `sudo -u deploy /opt/petrolyst/infra/deploy/deploy.sh <sha>` | | Re-deploy a kept release | `sudo -u deploy /opt/petrolyst/infra/deploy/deploy.sh <sha>`. Not across a change of Keycloak realm: see "Moving to realm petrolyst" |
| Roll back | see below | | Roll back | see below |
| Traefik dashboard | https://traefik.petrolyst.com (role `traefik-admin`) | | Traefik dashboard | https://traefik.petrolyst.com (role `traefik-admin`) |
| Trino shell | `docker exec -it fuel-monitoring-trino-1 trino` | | fuel-db shell | `docker exec -it petrolyst-fuel-db-1 psql -U postgres -d fuel` |
| fuel-db from the Mac | `ssh -L 5433:127.0.0.1:5433 petrolyst`, then connect to `localhost:5433`, database `fuel` | | fuel-db from the Mac | `ssh -L 5433:127.0.0.1:5433 petrolyst`, then connect to `localhost:5433`, database `fuel` |
| Logs | `docker compose -p fuel-monitoring logs -f superset` (or `-p traefik`) | | Logs | `docker compose -p petrolyst logs -f superset` (or `-p traefik`) |
| Superset break-glass | `docker exec -it fuel-monitoring-superset-1 superset fab ...` | | Superset break-glass | `docker exec -it petrolyst-superset-1 superset fab ...` |
**Manual deploy**, the only way to deploy (there is no CI runner). On the Mac, in this **Manual deploy**, the only way to deploy (there is no CI runner). On the Mac, in this
repository: repository:
@@ -223,12 +247,12 @@ sudo -u deploy /opt/petrolyst/infra/deploy/rollback.sh <sha>
Afterwards, `git revert` the bad commit and push, so `main` matches. A plain revert without the Afterwards, `git revert` the bad commit and push, so `main` matches. A plain revert without the
rollback script is stopped by the downgrade guard. rollback script is stopped by the downgrade guard.
**Data loaders** (fuel-db). Every table in `public` must belong to `fuel_owner`. `trino_ro` can **Data loaders** (fuel-db). Every table in `public` must belong to `fuel_owner`. `fuel_ro` can
read new tables only through `fuel_owner`'s default privileges, and PostgreSQL applies those read new tables only through `fuel_owner`'s default privileges, and PostgreSQL applies those
only to objects `fuel_owner` creates itself. A table that a loader creates as its own role stays only to objects `fuel_owner` creates itself. A table that a loader creates as its own role stays
unreadable for Trino. Create each loader role so that its sessions act as `fuel_owner`, and it unreadable for Superset and the Petrolyst server. Create each loader role so that its sessions act as `fuel_owner`, and it
cannot create anything as itself. Run this in cannot create anything as itself. Run this in
`docker exec -it fuel-monitoring-fuel-db-1 psql -U postgres -d fuel`: `docker exec -it petrolyst-fuel-db-1 psql -U postgres -d fuel`:
```sql ```sql
CREATE ROLE <loader> LOGIN PASSWORD '<password>'; CREATE ROLE <loader> LOGIN PASSWORD '<password>';
@@ -236,9 +260,74 @@ GRANT fuel_owner TO <loader> WITH INHERIT FALSE, SET TRUE;
ALTER ROLE <loader> SET role TO 'fuel_owner'; ALTER ROLE <loader> SET role TO 'fuel_owner';
``` ```
Tables it creates, hypertables included, then belong to `fuel_owner`, and Trino can read them. Tables it creates, hypertables included, then belong to `fuel_owner`, and `fuel_ro` can read them.
After `SET ROLE NONE`, it cannot create anything in `public`. To repair a table that another After `SET ROLE NONE`, it cannot create anything in `public`. To repair a table that another
role created, run `ALTER TABLE <t> OWNER TO fuel_owner`, then `GRANT SELECT ON <t> TO trino_ro`. role created, run `ALTER TABLE <t> OWNER TO fuel_owner`, then `GRANT SELECT ON <t> TO fuel_ro`.
**Removing the old project**, once, right before the next deploy; delete this paragraph when it
is done. The server still runs the Compose project `fuel-monitoring`, with Trino. Afterwards it runs
`petrolyst`, without it: containers are `petrolyst-<service>-1`, and the data volumes keep their
names (pinned in `compose.yaml`), so no data moves. The old containers must be gone first: two
PostgreSQL servers on one data volume corrupt it, and preflight refuses to deploy while any exists.
As root on the server (Superset is down from step 3 until the deploy finishes). The role keeps its
password, because a SCRAM password survives a rename:
```bash
S=/opt/petrolyst/secrets/fuel-monitoring
mv "$S/fuel_db_trino_password" "$S/fuel_db_ro_password" # 1
docker exec -i fuel-monitoring-fuel-db-1 psql -U postgres -d fuel -v ON_ERROR_STOP=1 \
-c 'ALTER ROLE trino_ro RENAME TO fuel_ro' \
-c 'ALTER ROLE fuel_ro SET default_transaction_read_only = on' # 2
docker rm -f $(docker ps -aq --filter label=com.docker.compose.project=fuel-monitoring) # 3
```
4. Deploy as usual ("Manual deploy"). Compose warns once that the volumes belong to another project;
that is expected. `superset-init` registers the connection "Fuel"; the smoke test checks that it
reads as `fuel_ro`, read-only.
5. Delete the old connection. It refuses if a dataset still uses it; then stop and look:
```bash
docker exec -i petrolyst-superset-1 /app/.venv/bin/python - <<'PY'
from superset.app import create_app
with create_app().app_context():
from superset import db
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
old = db.session.query(Database).filter_by(database_name="Fuel (Trino)").one_or_none()
if old:
used = db.session.query(SqlaTable).filter_by(database_id=old.id).count()
if used:
raise SystemExit(f"{used} datasets still use the old connection")
db.session.delete(old)
db.session.commit()
print("old connection deleted")
PY
```
6. Clean up: `rm /opt/petrolyst/env/fuel-monitoring.env`, `rmdir /opt/petrolyst/env`,
`docker image rm trinodb/trino:484`,
`docker network rm fuel-monitoring_backend fuel-monitoring_fuel-db-tunnel`.
7. Releases from before this deploy can no longer run, as their role is gone, and `rollback.sh`
stops on them. Remove them from `/opt/petrolyst/releases/`.
**Moving to realm `petrolyst`, one time.** A deploy of its own, after the deploy that removes Trino:
releases from before that one can no longer run, so a combined deploy leaves nothing to roll back to.
1. The realm is ready (Keycloak, above) and an admin has signed in on client `petrolyst` there. While
Superset still answers at atoma.petrolyst.com, add that address's redirect URI to the client by
hand, or nobody can sign in after step 3; remove it when the domain goes.
2. As root: `host/secrets-init.sh keycloak-secret`. The value goes into the new file
`shared/keycloak_petrolyst_realm_client_secret`, so nothing changes for anyone yet. Leave the old
file `shared/keycloak_petrolyst_client_secret` alone.
3. Deploy by "Manual deploy" only. That one step moves Superset and Traefik together.
4. **Prove it by hand:** an admin signs in to Superset and to the Traefik dashboard. The smoke tests
check redirects only, and pass with a wrong secret.
5. **To go back:** `rollback.sh <previous sha>` returns to the old realm with the old secret. Afterwards go
forward again by "Manual deploy" only: "Re-deploy a kept release" runs the old `lib.sh`, whose realm
address would override the new release's.
6. Not before 7 days of working sign-ins, and on the owner's word: retire client `petrolyst` in realm
`thedigitalbridge` and delete the old secret file. From then on there is no way back.
**Adding a project**: create `stacks/<name>/compose.yaml`, attach the public service to the **Adding a project**: create `stacks/<name>/compose.yaml`, attach the public service to the
external network `proxy`, and set `traefik.*` labels with a unique router name and external network `proxy`, and set `traefik.*` labels with a unique router name and
@@ -266,7 +355,6 @@ Docker Desktop, with a scratch `/opt/petrolyst`. Production differences in that
Results: Results:
- The fresh install took the init path. - The fresh install took the init path.
- An identical re-run recreated nothing and left `ARTIFACTS` byte-identical. - An identical re-run recreated nothing and left `ARTIFACTS` byte-identical.
- A Trino-only change recreated only `trino`.
- A Superset change stopped, backed up, migrated and recreated only `superset`. - A Superset change stopped, backed up, migrated and recreated only `superset`.
- A change to `superset/init.sh` alone did the same, and the changed script ran. - A change to `superset/init.sh` alone did the same, and the changed script ran.
- A same-SHA upload with different content was refused. - A same-SHA upload with different content was refused.
@@ -283,13 +371,12 @@ Results:
- Retention pruned read-only releases. - Retention pruned read-only releases.
- Retention removed a queued upload that a plain `rsync -a` had given an old checkout's - Retention removed a queued upload that a plain `rsync -a` had given an old checkout's
directory times. It kept one uploaded with `--omit-dir-times`. directory times. It kept one uploaded with `--omit-dir-times`.
- In `fuel-db`, a loader that created a table as its own role left it unreadable for `trino_ro`. - In `fuel-db`, a loader that created a table as its own role left it unreadable for the read-only
With the "Data loaders" recipe, tables (hypertables too) belonged to `fuel_owner` and Trino role. With the "Data loaders" recipe, tables (hypertables too) belonged to `fuel_owner` and that
could read them. After `SET ROLE NONE`, the loader could not create anything. role could read them. After `SET ROLE NONE`, the loader could not create anything.
- Smoke tests passed, including the redirects to the real Keycloak with `client_id=petrolyst` - Smoke tests passed, including the redirects to the real Keycloak with `client_id=petrolyst`
and PKCE S256. and PKCE S256.
- TLS 1.1 and unknown SNI were refused, and HTTP redirected to HTTPS. - TLS 1.1 and unknown SNI were refused, and HTTP redirected to HTTPS.
- Trino and `trino_ro` both refused writes.
Not verified locally: Not verified locally:
- Let's Encrypt issuance; - Let's Encrypt issuance;
+1 -1
View File
@@ -44,7 +44,7 @@ resolve_artifacts() {
fi fi
chmod -R a-w "$release" chmod -R a-w "$release"
load_artifacts "$release" load_artifacts "$release"
log "artifacts: superset $SUPERSET_IMAGE_TAG, trino config $TRINO_CONFIG_HASH, traefik config $TRAEFIK_CONFIG_HASH" log "artifacts: superset $SUPERSET_IMAGE_TAG, traefik config $TRAEFIK_CONFIG_HASH"
} }
# prepare_fuel_monitoring <release>: before anything live changes, build the # prepare_fuel_monitoring <release>: before anything live changes, build the
+55 -21
View File
@@ -17,11 +17,13 @@ ROLLBACK_MARK=$STATE/rollback-in-progress
ATOMA_HOST=${ATOMA_HOST:-atoma.petrolyst.com} ATOMA_HOST=${ATOMA_HOST:-atoma.petrolyst.com}
TRAEFIK_HOST=${TRAEFIK_HOST:-traefik.petrolyst.com} TRAEFIK_HOST=${TRAEFIK_HOST:-traefik.petrolyst.com}
KEYCLOAK_REALM_URL=${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/thedigitalbridge}
PETROLYST_ROOT=$ROOT PETROLYST_ROOT=$ROOT
export ATOMA_HOST TRAEFIK_HOST KEYCLOAK_REALM_URL PETROLYST_ROOT # The Keycloak realm is not set here: each release's compose files name their own, with the secret
# file that belongs to it. A rollback, which runs this file from the release it leaves, therefore
# returns to the old release's realm and secret. Exporting a value here would override them.
export ATOMA_HOST TRAEFIK_HOST PETROLYST_ROOT
# --host pins the daemon, as in petrolyst-ui/deploy/deploy.sh: a stale # --host pins the daemon, as in petrolyst-web/deploy/deploy.sh: a stale
# `docker context use` must not send this deploy to another daemon. # `docker context use` must not send this deploy to another daemon.
DOCKER=(docker --host unix:///var/run/docker.sock) DOCKER=(docker --host unix:///var/run/docker.sock)
@@ -33,19 +35,19 @@ SUPERSET_INPUTS=(
stacks/fuel-monitoring/superset/init.sh stacks/fuel-monitoring/superset/init.sh
petrolyst-insights/superset_config.py petrolyst-insights/superset_config.py
petrolyst-insights/petrolyst_security.py petrolyst-insights/petrolyst_security.py
petrolyst-insights/roles
petrolyst-insights/assets/branding petrolyst-insights/assets/branding
petrolyst-insights/patches petrolyst-insights/patches
) )
# Every file the stacks need from /opt/petrolyst (host/secrets-init.sh). # Every file the stacks need from /opt/petrolyst (host/secrets-init.sh).
# The Keycloak client secret is not listed: it depends on the release (check_keycloak_secrets).
REQUIRED_FILES=( REQUIRED_FILES=(
secrets/shared/keycloak_petrolyst_client_secret
secrets/traefik/oauth2_proxy_cookie_secret secrets/traefik/oauth2_proxy_cookie_secret
secrets/fuel-monitoring/superset_secret_key secrets/fuel-monitoring/superset_secret_key
secrets/fuel-monitoring/superset_db_password secrets/fuel-monitoring/superset_db_password
secrets/fuel-monitoring/fuel_db_password secrets/fuel-monitoring/fuel_db_password
secrets/fuel-monitoring/fuel_db_trino_password secrets/fuel-monitoring/fuel_db_ro_password
env/fuel-monitoring.env
) )
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; } log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
@@ -82,7 +84,6 @@ hash_inputs() {
compute_artifacts() { compute_artifacts() {
printf 'SUPERSET_IMAGE_TAG=%s\n' "$(hash_inputs "$1" "${SUPERSET_INPUTS[@]}")" printf 'SUPERSET_IMAGE_TAG=%s\n' "$(hash_inputs "$1" "${SUPERSET_INPUTS[@]}")"
printf 'TRINO_CONFIG_HASH=%s\n' "$(hash_inputs "$1" stacks/fuel-monitoring/trino/etc)"
# Only the static config: dynamic/ is watched and applies without a restart. # Only the static config: dynamic/ is watched and applies without a restart.
printf 'TRAEFIK_CONFIG_HASH=%s\n' "$(hash_inputs "$1" traefik/static)" printf 'TRAEFIK_CONFIG_HASH=%s\n' "$(hash_inputs "$1" traefik/static)"
} }
@@ -91,16 +92,16 @@ compute_artifacts() {
load_artifacts() { load_artifacts() {
local file=$1/ARTIFACTS line key value local file=$1/ARTIFACTS line key value
[ -s "$file" ] || die "$file is missing" [ -s "$file" ] || die "$file is missing"
unset SUPERSET_IMAGE_TAG TRINO_CONFIG_HASH TRAEFIK_CONFIG_HASH unset SUPERSET_IMAGE_TAG TRAEFIK_CONFIG_HASH
while IFS= read -r line; do while IFS= read -r line; do
key=${line%%=*} key=${line%%=*}
value=${line#*=} value=${line#*=}
[[ $key =~ ^(SUPERSET_IMAGE_TAG|TRINO_CONFIG_HASH|TRAEFIK_CONFIG_HASH)$ && $value =~ ^[0-9a-f]{16}$ ]] || [[ $key =~ ^(SUPERSET_IMAGE_TAG|TRAEFIK_CONFIG_HASH)$ && $value =~ ^[0-9a-f]{16}$ ]] ||
die "unexpected line in $file: $line" die "unexpected line in $file: $line"
printf -v "$key" '%s' "$value" printf -v "$key" '%s' "$value"
export "${key?}" export "${key?}"
done <"$file" done <"$file"
[ -n "${SUPERSET_IMAGE_TAG:-}" ] && [ -n "${TRINO_CONFIG_HASH:-}" ] && [ -n "${TRAEFIK_CONFIG_HASH:-}" ] || [ -n "${SUPERSET_IMAGE_TAG:-}" ] && [ -n "${TRAEFIK_CONFIG_HASH:-}" ] ||
die "$file is incomplete" die "$file is incomplete"
} }
@@ -127,15 +128,31 @@ preflight() {
for f in "${REQUIRED_FILES[@]}"; do for f in "${REQUIRED_FILES[@]}"; do
[ -s "$ROOT/$f" ] || die "missing or empty: $ROOT/$f (run host/secrets-init.sh as root)" [ -s "$ROOT/$f" ] || die "missing or empty: $ROOT/$f (run host/secrets-init.sh as root)"
done done
# oauth2-proxy uses the client secret file byte for byte. check_keycloak_secrets "$release"
f=$ROOT/secrets/shared/keycloak_petrolyst_client_secret
[ "$(tail -c1 "$f" | wc -l)" -eq 0 ] ||
die "$f ends with a newline; store it again with: host/secrets-init.sh keycloak-secret"
bad=$(find "$release/petrolyst-insights" \( -name 'Dockerfile*' -o -name '*compose*.y*ml' \ bad=$(find "$release/petrolyst-insights" \( -name 'Dockerfile*' -o -name '*compose*.y*ml' \
-o -name '*.env' -o -name '.env' -o -path '*/.gitea/*' -o -path '*/.github/*' \) -print) -o -name '*.env' -o -name '.env' -o -path '*/.gitea/*' -o -path '*/.github/*' \) -print)
[ -z "$bad" ] || die "petrolyst-insights/ holds customization only; found: $bad" [ -z "$bad" ] || die "petrolyst-insights/ holds customization only; found: $bad"
compose "$release" traefik config -q compose "$release" traefik config -q
while IFS= read -r dir; do compose "$release" "$dir" config -q; done < <(stack_dirs "$release") while IFS= read -r dir; do compose "$release" "$dir" config -q; done < <(stack_dirs "$release")
# The stack was the Compose project "fuel-monitoring" until 29 Oct 2026; "petrolyst" uses the
# same data volumes, so both must never run at once (README "One-time steps after 29 Oct 2026").
[ -z "$("${DOCKER[@]}" ps -aq --filter label=com.docker.compose.project=fuel-monitoring)" ] ||
die "containers of the old project fuel-monitoring exist; remove them first (README \"Removing the old project\")"
}
# check_keycloak_secrets <release>: the client secret that release's compose files name, which
# differs per realm. So a rollback needs only its target's secret, not the one of the release it leaves.
check_keycloak_secrets() {
local release=$1 names name f
names=$(cat "$release/stacks/fuel-monitoring/compose.yaml" "$release/traefik/compose.yaml" |
grep -oE 'secrets/shared/keycloak_[a-z_]+' | sort -u) || die "$release names no Keycloak client secret"
for name in $names; do
f=$ROOT/$name
[ -s "$f" ] || die "missing or empty: $f (run host/secrets-init.sh as root)"
# oauth2-proxy uses the client secret file byte for byte.
[ "$(tail -c1 "$f" | wc -l)" -eq 0 ] ||
die "$f ends with a newline; store it again"
done
} }
# promote <release> <sha>: make the release the live tree. --chmod applies to # promote <release> <sha>: make the release the live tree. --chmod applies to
@@ -231,7 +248,7 @@ probe() {
} }
smoke_tests() { smoke_tests() {
local i body code location local i body code location realm
log "smoke tests" log "smoke tests"
# First deploy: Let's Encrypt may need a minute to issue the certificate. # First deploy: Let's Encrypt may need a minute to issue the certificate.
for i in $(seq 1 24); do for i in $(seq 1 24); do
@@ -241,23 +258,40 @@ smoke_tests() {
sleep 5 sleep 5
done done
# The realm this release runs on. These tests check redirects only, not a real sign-in: they
# pass with a wrong client secret, so a person signs in after a change of realm or secret.
realm=$(fm exec -T superset printenv KEYCLOAK_REALM_URL) || die "Superset has no KEYCLOAK_REALM_URL"
read -r code location < <(probe "$ATOMA_HOST" /login/) read -r code location < <(probe "$ATOMA_HOST" /login/)
[ "$code" = 302 ] && [[ $location == "https://$ATOMA_HOST/login/keycloak"* ]] || [ "$code" = 302 ] && [[ $location == "https://$ATOMA_HOST/login/keycloak"* ]] ||
die "/login/ should redirect to /login/keycloak, got $code $location" die "/login/ should redirect to /login/keycloak, got $code $location"
read -r code location < <(probe "$ATOMA_HOST" /login/keycloak) read -r code location < <(probe "$ATOMA_HOST" /login/keycloak)
[ "$code" = 302 ] && [[ $location == "$KEYCLOAK_REALM_URL/protocol/openid-connect/auth?"* ]] && [ "$code" = 302 ] && [[ $location == "$realm/protocol/openid-connect/auth?"* ]] &&
[[ $location == *client_id=petrolyst* && $location == *code_challenge_method=S256* ]] || [[ $location == *client_id=petrolyst* && $location == *code_challenge_method=S256* ]] ||
die "/login/keycloak should redirect to Keycloak with client_id=petrolyst and PKCE S256, got $code $location" die "/login/keycloak should redirect to Keycloak with client_id=petrolyst and PKCE S256, got $code $location"
read -r code location < <(probe "$TRAEFIK_HOST" /) read -r code location < <(probe "$TRAEFIK_HOST" /)
[ "$code" = 302 ] && [[ $location == "$KEYCLOAK_REALM_URL/"* || $location == *"/oauth2/start"* ]] || [ "$code" = 302 ] && [[ $location == "$realm/"* || $location == *"/oauth2/start"* ]] ||
die "https://$TRAEFIK_HOST/ should redirect to Keycloak, got $code $location" die "https://$TRAEFIK_HOST/ should redirect to Keycloak, got $code $location"
fm exec -T trino trino --execute 'SELECT count(*) FROM fuel.information_schema.schemata' >/dev/null || # Superset's own connection "Fuel" (fuel_ro, password from /run/secrets) reads fuel-db and
die "Trino cannot read the fuel catalog" # cannot write to it.
fm exec -T superset curl -fsS --max-time 10 http://trino:8080/v1/info >/dev/null || fm exec -T superset /app/.venv/bin/python - <<'PY' || die "Superset cannot read fuel-db as fuel_ro, or fuel_ro can write"
die "Superset cannot reach Trino" import sys
from superset.app import create_app
with create_app().app_context():
from superset import db
from superset.models.core import Database
fuel = db.session.query(Database).filter_by(database_name="Fuel").one()
with fuel.get_sqla_engine() as engine, engine.connect() as conn:
user, read_only = conn.exec_driver_sql(
"SELECT current_user, current_setting('transaction_read_only')").one()
sys.exit(0 if (user, read_only) == ("fuel_ro", "on") else 1)
PY
log "smoke tests passed" log "smoke tests passed"
} }
+1 -1
View File
@@ -40,7 +40,7 @@ if [ "$(cat /etc/docker/daemon.json 2>/dev/null)" != "$daemon_json" ]; then
fi fi
systemctl enable --now docker systemctl enable --now docker
echo "== swap (4 GB) for Trino, Superset and two Postgres in 7.8 GB" echo "== swap (4 GB) for Superset and two Postgres in 7.8 GB"
if ! swapon --show=NAME --noheadings | grep -qx /swapfile; then if ! swapon --show=NAME --noheadings | grep -qx /swapfile; then
[ -e /swapfile ] || fallocate -l 4G /swapfile [ -e /swapfile ] || fallocate -l 4G /swapfile
chmod 600 /swapfile chmod 600 /swapfile
+9 -20
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Server secrets and env files, run as root after bootstrap.sh. # Server secrets, run as root after bootstrap.sh.
# secrets-init.sh generate every missing secret; never overwrites one # secrets-init.sh generate every missing secret; never overwrites one
# secrets-init.sh keycloak-secret store the client secret of Keycloak client # secrets-init.sh keycloak-secret store the client secret of Keycloak client
# "petrolyst" (Clients > petrolyst > Credentials), # "petrolyst" of realm "petrolyst"
# (Clients > petrolyst > Credentials),
# read from the terminal or stdin # read from the terminal or stdin
# #
# Layout: /opt/petrolyst/secrets/{shared,traefik,fuel-monitoring}/ are root:deploy # Layout: /opt/petrolyst/secrets/{shared,traefik,fuel-monitoring}/ are root:deploy
@@ -17,7 +18,7 @@ getent group deploy >/dev/null || die "group deploy is missing: run host/bootstr
ROOT=/opt/petrolyst ROOT=/opt/petrolyst
SECRETS=$ROOT/secrets SECRETS=$ROOT/secrets
umask 077 umask 077
install -d -o root -g deploy -m 0750 "$SECRETS" "$SECRETS/shared" "$SECRETS/traefik" "$SECRETS/fuel-monitoring" "$ROOT/env" install -d -o root -g deploy -m 0750 "$SECRETS" "$SECRETS/shared" "$SECRETS/traefik" "$SECRETS/fuel-monitoring"
# write_secret <path> <value>: no trailing newline (oauth2-proxy reads the file # write_secret <path> <value>: no trailing newline (oauth2-proxy reads the file
# byte for byte), replaced atomically. # byte for byte), replaced atomically.
@@ -50,8 +51,8 @@ case ${1:-all} in
fi fi
value=$(printf '%s' "$value" | tr -d '[:space:]') value=$(printf '%s' "$value" | tr -d '[:space:]')
[ -n "$value" ] || die "empty secret" [ -n "$value" ] || die "empty secret"
write_secret "$SECRETS/shared/keycloak_petrolyst_client_secret" "$value" write_secret "$SECRETS/shared/keycloak_petrolyst_realm_client_secret" "$value"
echo "stored $SECRETS/shared/keycloak_petrolyst_client_secret" echo "stored $SECRETS/shared/keycloak_petrolyst_realm_client_secret"
exit 0 exit 0
;; ;;
all) ;; all) ;;
@@ -62,26 +63,14 @@ esac
generate "$SECRETS/fuel-monitoring/superset_secret_key" 32 generate "$SECRETS/fuel-monitoring/superset_secret_key" 32
generate "$SECRETS/fuel-monitoring/superset_db_password" 32 generate "$SECRETS/fuel-monitoring/superset_db_password" 32
generate "$SECRETS/fuel-monitoring/fuel_db_password" 32 generate "$SECRETS/fuel-monitoring/fuel_db_password" 32
generate "$SECRETS/fuel-monitoring/fuel_db_trino_password" 32 generate "$SECRETS/fuel-monitoring/fuel_db_ro_password" 32
# 32 hex characters = a 32-byte cookie key, as oauth2-proxy requires. # 32 hex characters = a 32-byte cookie key, as oauth2-proxy requires.
generate "$SECRETS/traefik/oauth2_proxy_cookie_secret" 16 generate "$SECRETS/traefik/oauth2_proxy_cookie_secret" 16
# Trino reads the catalog password from its environment (${ENV:...}); keep the if [ ! -s "$SECRETS/shared/keycloak_petrolyst_realm_client_secret" ]; then
# env file in step with the secret file that fuel-db's init script uses.
env_file=$ROOT/env/fuel-monitoring.env
line="FUEL_DB_TRINO_PASSWORD=$(cat "$SECRETS/fuel-monitoring/fuel_db_trino_password")"
if ! grep -qxF "$line" "$env_file" 2>/dev/null; then
printf '%s\n' "$line" >"$env_file.tmp"
chown root:deploy "$env_file.tmp"
chmod 0440 "$env_file.tmp"
mv "$env_file.tmp" "$env_file"
echo "wrote $env_file"
fi
if [ ! -s "$SECRETS/shared/keycloak_petrolyst_client_secret" ]; then
echo echo
echo "Still missing: the Keycloak client secret. Copy it from Keycloak" echo "Still missing: the Keycloak client secret. Copy it from Keycloak"
echo "(realm thedigitalbridge > Clients > petrolyst > Credentials), then run:" echo "(realm petrolyst > Clients > petrolyst > Credentials), then run:"
echo " $0 keycloak-secret" echo " $0 keycloak-secret"
exit 1 exit 1
fi fi
-56
View File
@@ -1,56 +0,0 @@
{
"clients": [
{
"clientId": "petrolyst",
"name": "Petrolyst",
"description": "Petrolyst apps on the petrolyst server: Superset at atoma.petrolyst.com and the Traefik dashboard at traefik.petrolyst.com (oauth2-proxy). Managed from petrolyst-infra/keycloak/.",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"baseUrl": "https://atoma.petrolyst.com/",
"redirectUris": [
"https://atoma.petrolyst.com/oauth-authorized/keycloak",
"https://traefik.petrolyst.com/oauth2/callback"
],
"webOrigins": ["+"],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "https://atoma.petrolyst.com/*##https://traefik.petrolyst.com/*"
},
"protocolMappers": [
{
"name": "petrolyst client roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-client-role-mapper",
"consentRequired": false,
"config": {
"usermodel.clientRoleMapping.clientId": "petrolyst",
"multivalued": "true",
"claim.name": "petrolyst_roles",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"introspection.token.claim": "true"
}
}
]
}
],
"roles": {
"client": {
"petrolyst": [
{ "name": "superset-admin", "description": "Superset Admin at atoma.petrolyst.com" },
{ "name": "superset-editor", "description": "Superset Alpha: build charts and dashboards" },
{ "name": "superset-viewer", "description": "Superset Gamma: view dashboards shared with the role" },
{ "name": "traefik-admin", "description": "Traefik dashboard at traefik.petrolyst.com" }
]
}
}
}
+114
View File
@@ -0,0 +1,114 @@
{
"clients": [
{
"clientId": "petrolyst",
"name": "Petrolyst administration",
"description": "Superset and the Traefik dashboard (oauth2-proxy), for our own people, with MFA (browser flow override petrolyst-mfa, set by hand). Managed from petrolyst-infra/keycloak/.",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"baseUrl": "https://app.petrolyst.com/",
"redirectUris": [
"https://app.petrolyst.com/oauth-authorized/keycloak",
"https://traefik.petrolyst.com/oauth2/callback"
],
"webOrigins": ["+"],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "https://app.petrolyst.com/*##https://traefik.petrolyst.com/*"
},
"protocolMappers": [
{
"name": "petrolyst client roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-client-role-mapper",
"consentRequired": false,
"config": {
"usermodel.clientRoleMapping.clientId": "petrolyst",
"multivalued": "true",
"claim.name": "petrolyst_roles",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true",
"introspection.token.claim": "true"
}
}
]
},
{
"clientId": "petrolyst-app",
"name": "Petrolyst",
"description": "The Petrolyst app (petrolyst-platform server/), for customers' users. Password only (PD-21). The customer comes from the person's top-level group. Managed from petrolyst-infra/keycloak/.",
"enabled": true,
"protocol": "openid-connect",
"publicClient": false,
"clientAuthenticatorType": "client-secret",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"frontchannelLogout": true,
"baseUrl": "https://app.petrolyst.com/app/",
"redirectUris": [
"https://app.petrolyst.com/app/auth/callback"
],
"webOrigins": [],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "https://app.petrolyst.com/app/*"
},
"protocolMappers": [
{
"name": "petrolyst client roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-client-role-mapper",
"consentRequired": false,
"config": {
"usermodel.clientRoleMapping.clientId": "petrolyst-app",
"multivalued": "true",
"claim.name": "petrolyst_roles",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "false",
"userinfo.token.claim": "false",
"introspection.token.claim": "false"
}
},
{
"name": "petrolyst groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"consentRequired": false,
"config": {
"full.path": "true",
"claim.name": "petrolyst_groups",
"id.token.claim": "true",
"access.token.claim": "false",
"userinfo.token.claim": "false",
"introspection.token.claim": "false"
}
}
]
}
],
"roles": {
"client": {
"petrolyst": [
{ "name": "superset-admin", "description": "Superset Admin" },
{ "name": "superset-editor", "description": "Superset Alpha: build charts and dashboards" },
{ "name": "superset-viewer", "description": "Superset Gamma: view dashboards shared with the role" },
{ "name": "traefik-admin", "description": "Traefik dashboard at traefik.petrolyst.com" }
],
"petrolyst-app": [
{ "name": "reader", "description": "Reads their customer's data in the Petrolyst app; no role in Superset. Mapped onto each customer's group." }
]
}
}
}
+4
View File
@@ -0,0 +1,4 @@
# Built by build.sh
dist/
# Satoshi: licence forbids committing it (build.sh)
petrolyst/login/resources/fonts/Satoshi-*
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Build dist/petrolyst-theme.jar, ready for Keycloak's providers/ folder. Pass the folder holding Satoshi-Variable.woff2 (normally
# petrolyst-platform/web/src/fonts/satoshi, fetched there from Fontshare). The font goes only into the built file, never into Git:
# its licence forbids distributing it. Without it, the pages fall back to Inter.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SATOSHI="${1:-}"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/petrolyst-theme.XXXXXX")"; trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/META-INF" "$WORK/theme"
cp -R "$HERE/petrolyst" "$WORK/theme/petrolyst"
find "$WORK" -name .DS_Store -delete
if [ -n "$SATOSHI" ]; then cp "$SATOSHI/Satoshi-Variable.woff2" "$WORK/theme/petrolyst/login/resources/fonts/"; else echo "no Satoshi folder given: headings fall back to Inter"; fi
printf '{"themes":[{"name":"petrolyst","types":["login"]}]}\n' > "$WORK/META-INF/keycloak-themes.json"
mkdir -p "$HERE/dist"; rm -f "$HERE/dist/petrolyst-theme.jar"
(cd "$WORK" && zip -qr "$HERE/dist/petrolyst-theme.jar" META-INF theme)
echo "built $HERE/dist/petrolyst-theme.jar"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# The theme's copies of the brand tokens match their home, Petrolyst/design/. Says "not run" in a lone clone of this repository.
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE="$HERE/../../../design"
COPIES="$HERE/petrolyst/login/resources/css/brand"
[ -d "$SOURCE" ] || { echo "not run: no Petrolyst/design/ next to this repository"; exit 0; }
status=0
for pair in brand/07_UI_Tokens/petrolyst_tokens.css identity_layer/02_Tokens/product_identity_layer.css; do
name="$(basename "$pair")"
if cmp -s "$SOURCE/$pair" "$COPIES/$name"; then echo "same $name"; else echo "DIFFERS $name"; status=1; fi
done
exit $status
+24
View File
@@ -0,0 +1,24 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=false; section>
<#if section = "header">
<#if messageHeader??>
${kcSanitize(msg("${messageHeader}"))?no_esc}
<#else>
${message.summary}
</#if>
<#elseif section = "form">
<div id="kc-info-message">
<p class="instruction"><#if messageHeader??>${message.summary}</#if><#if requiredActions??><#list requiredActions>: <b><#items as reqActionItem>${kcSanitize(msg("requiredAction.${reqActionItem}"))?no_esc}<#sep>, </#items></b></#list><#else></#if></p>
<#if skipLink??>
<#else>
<#if pageRedirectUri?has_content>
<p><a href="${pageRedirectUri}">${kcSanitize(msg("backToApplication"))?no_esc}</a></p>
<#elseif actionUri?has_content>
<p><a href="${actionUri}">${kcSanitize(msg("proceedWithAction"))?no_esc}</a></p>
<#elseif (client.baseUrl)?has_content>
<p><a href="${client.baseUrl}">${kcSanitize(msg("backToApplication"))?no_esc}</a></p>
</#if>
</#if>
</div>
</#if>
</@layout.registrationLayout>
@@ -0,0 +1,7 @@
loginTitle=Sign in to Petrolyst
loginTitleHtml=
loginAccountTitle=Sign in to Petrolyst
doLogIn=Sign in
logoutConfirmTitle=Log out of Petrolyst
logoutConfirmHeader=Do you want to log out?
doLogout=Log out
@@ -0,0 +1,190 @@
:root {
--brand-navy: #0B1D33;
--brand-teal: #009688;
--brand-cyan: #00B6E6;
--brand-sand: #F2EBDD;
--brand-graphite: #3F4752;
/* PD-26: the Etihad-inspired restyle. Pearl carries tags and avatars; gold is an editorial
accent only, never on controls and never carrying state. */
--brand-gold: #AA8951;
--brand-pearl: #EBE2D2;
/* The chrome: the dark slate shell (header, side menus, hero bands). The same in light and
dark mode, like the reference design; only the content below it follows the device. */
--surface-chrome: #2A3945;
--surface-chrome-deep: #1F2A33;
--surface-chrome-active: #334654;
--border-chrome: #4D5562;
--text-on-chrome: #F8FAFC;
--text-on-chrome-muted: #B7C1C9;
--surface-page: #FFFFFF;
--surface-subtle: #F9FAFB;
--surface-card: #FFFFFF;
--text-primary: #0B1D33;
--text-secondary: #475569;
--text-muted: #64748B;
--border-default: #E3E7EA;
--border-strong: #C9D0D6;
/* Actions wear the logo's green (PD-26, revised on the owner's word: the Y's colour is the
brand's one strong colour, so the controls echo it). Gold stays an editorial accent only. */
--action-primary: #00695C;
--action-primary-foreground: #FFFFFF;
--action-primary-hover: #005B50;
--action-primary-active: #004D44;
--action-link: #00695C;
--focus-ring-inner: #FFFFFF;
--focus-ring-outer: #0B1D33;
--focus-ring-inner-width: 2px;
--focus-ring-outer-width: 2px;
--focus-ring-offset: 2px;
--status-normal: #0F7B6C;
--status-normal-foreground: #FFFFFF;
--status-live: #007C91;
--status-live-foreground: #FFFFFF;
--status-warning: #B45309;
--status-warning-foreground: #FFFFFF;
--status-critical: #B3261E;
--status-critical-foreground: #FFFFFF;
--status-offline: #52606D;
--status-offline-foreground: #FFFFFF;
--status-stale: #7C8796;
--status-stale-foreground: #0B1D33;
--status-info: #2563EB;
--status-info-foreground: #FFFFFF;
--font-display: "Satoshi", "Inter", system-ui, sans-serif;
--font-ui: "Inter", system-ui, sans-serif;
--text-display-xl: 64px;
--text-h1: 48px;
--text-h2: 36px;
--text-h3: 28px;
--text-body-lg: 20px;
--text-body: 16px;
--text-ui: 14px;
--text-caption: 12px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--space-16: 64px;
--space-24: 96px;
/* PD-26 shapes: large soft cards, small chips, pill buttons. */
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 20px;
--radius-pill: 999px;
--shadow-card: 0 2px 4px rgb(11 29 51 / 0.04), 0 12px 28px -8px rgb(11 29 51 / 0.12);
--shadow-pop: 0 6px 16px rgb(11 29 51 / 0.12), 0 20px 48px -12px rgb(11 29 51 / 0.28);
}
[data-theme="dark"] {
--surface-page: #1F2A33;
--surface-subtle: #243038;
--surface-card: #2A3945;
--text-primary: #F8FAFC;
--text-secondary: #C7CFD6;
--text-muted: #9AA6AF;
--border-default: #3C4854;
--border-strong: #76828E;
--action-primary: #35C5B6;
--action-primary-foreground: #0B1D33;
--action-primary-hover: #4CD3C4;
--action-primary-active: #2DB4A6;
--action-link: #35C5B6;
--focus-ring-inner: #1F2A33;
--focus-ring-outer: #F8FAFC;
--status-normal: #35C5B6;
--status-normal-foreground: #0B1D33;
--status-live: #48CFF2;
--status-live-foreground: #0B1D33;
--status-warning: #F59E0B;
--status-warning-foreground: #0B1D33;
--status-critical: #FF6B6B;
--status-critical-foreground: #0B1D33;
--status-offline: #94A3B8;
--status-offline-foreground: #0B1D33;
--status-stale: #CBD5E1;
--status-stale-foreground: #0B1D33;
--status-info: #60A5FA;
--status-info-foreground: #0B1D33;
--shadow-card: 0 2px 4px rgb(0 0 0 / 0.2), 0 12px 28px -8px rgb(0 0 0 / 0.4);
--shadow-pop: 0 6px 16px rgb(0 0 0 / 0.35), 0 20px 48px -12px rgb(0 0 0 / 0.55);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--surface-page: #1F2A33;
--surface-subtle: #243038;
--surface-card: #2A3945;
--text-primary: #F8FAFC;
--text-secondary: #C7CFD6;
--text-muted: #9AA6AF;
--border-default: #3C4854;
--border-strong: #76828E;
--action-primary: #35C5B6;
--action-primary-foreground: #0B1D33;
--action-primary-hover: #4CD3C4;
--action-primary-active: #2DB4A6;
--action-link: #35C5B6;
--focus-ring-inner: #1F2A33;
--focus-ring-outer: #F8FAFC;
--status-normal: #35C5B6;
--status-normal-foreground: #0B1D33;
--status-live: #48CFF2;
--status-live-foreground: #0B1D33;
--status-warning: #F59E0B;
--status-warning-foreground: #0B1D33;
--status-critical: #FF6B6B;
--status-critical-foreground: #0B1D33;
--status-offline: #94A3B8;
--status-offline-foreground: #0B1D33;
--status-stale: #CBD5E1;
--status-stale-foreground: #0B1D33;
--status-info: #60A5FA;
--status-info-foreground: #0B1D33;
--shadow-card: 0 2px 4px rgb(0 0 0 / 0.2), 0 12px 28px -8px rgb(0 0 0 / 0.4);
--shadow-pop: 0 6px 16px rgb(0 0 0 / 0.35), 0 20px 48px -12px rgb(0 0 0 / 0.55);
}
}
/* Recommended primary action implementation */
.petrolyst-action-primary {
background: var(--action-primary);
color: var(--action-primary-foreground);
border: 1px solid var(--action-primary);
}
.petrolyst-action-primary:hover {
background: var(--action-primary-hover);
border-color: var(--action-primary-hover);
}
.petrolyst-action-primary:active {
background: var(--action-primary-active);
border-color: var(--action-primary-active);
}
.petrolyst-action-primary:focus-visible {
outline: var(--focus-ring-outer-width) solid var(--focus-ring-outer);
outline-offset: calc(var(--focus-ring-offset) + var(--focus-ring-inner-width));
box-shadow: 0 0 0 var(--focus-ring-inner-width) var(--focus-ring-inner);
}
@@ -0,0 +1,112 @@
:root {
--petroleum-50:#F7F9FA; --petroleum-100:#EEF2F4; --petroleum-200:#DDE5E9;
--petroleum-300:#C3D0D7; --petroleum-400:#96A9B4; --petroleum-500:#6C818E;
--petroleum-600:#506572; --petroleum-700:#394E5C; --petroleum-800:#233746;
--petroleum-900:#132636; --petroleum-950:#0A1825;
--surface-live:#FFFFFF;
--surface-live-alt:var(--petroleum-50);
--surface-context:#F2EBDD;
--surface-report:#F7F1E8;
--surface-empty:#F5EFE4;
/* Active semantic override of the base Brand System */
--surface-subtle:var(--petroleum-50);
--text-secondary:var(--petroleum-600);
--text-muted:var(--petroleum-600);
--border-default:var(--petroleum-200);
--border-subtle:var(--petroleum-200);
--border-control:var(--petroleum-500);
--border-strong:var(--petroleum-500);
--numeric-family:"Inter",system-ui,sans-serif;
--numeric-weight:600;
--numeric-unit-weight:500;
--numeric-unit-color:var(--petroleum-600);
--numeric-value-unit-ratio:.46;
--product-icon-color:#0B1D33;
--product-icon-live:#007C91;
--product-icon-action:#009688;
--product-icon-warning:#B45309;
--product-icon-critical:#B3261E;
--product-icon-offline:#52606D;
--product-icon-info:#2563EB;
--y-branch-angle-from-vertical:35deg;
--y-divider-angle:12deg;
}
[data-theme="dark"] {
--surface-live:#2A3945;
--surface-live-alt:#243038;
--surface-context:#242A2F;
--surface-report:#20272D;
--surface-empty:#1C242B;
--surface-subtle:#1F2A33;
--text-secondary:#C7CFD6;
--text-muted:#9AA6AF;
--border-default:#3C4854;
--border-subtle:#3C4854;
--border-control:#76828E;
--border-strong:#8B96A1;
--numeric-unit-color:#C7CFD6;
--product-icon-color:#F8FAFC;
--product-icon-live:#48CFF2;
--product-icon-action:#35C5B6;
--product-icon-warning:#F59E0B;
--product-icon-critical:#FF6B6B;
--product-icon-offline:#94A3B8;
--product-icon-info:#60A5FA;
}
@media (prefers-color-scheme:dark) {
:root:not([data-theme="light"]) {
--surface-live:#2A3945;
--surface-live-alt:#243038;
--surface-context:#242A2F;
--surface-report:#20272D;
--surface-empty:#1C242B;
--surface-subtle:#1F2A33;
--text-secondary:#C7CFD6;
--text-muted:#9AA6AF;
--border-default:#3C4854;
--border-subtle:#3C4854;
--border-control:#76828E;
--border-strong:#8B96A1;
--numeric-unit-color:#C7CFD6;
--product-icon-color:#F8FAFC;
--product-icon-live:#48CFF2;
--product-icon-action:#35C5B6;
--product-icon-warning:#F59E0B;
--product-icon-critical:#FF6B6B;
--product-icon-offline:#94A3B8;
--product-icon-info:#60A5FA;
}
}
.petrolyst-reading {
display:inline-flex; align-items:baseline; gap:.14em;
font-family:var(--numeric-family);
font-variant-numeric:tabular-nums slashed-zero;
font-feature-settings:"tnum" 1,"zero" 1;
}
.petrolyst-reading__value {font-weight:var(--numeric-weight);letter-spacing:-.025em;line-height:.95;}
.petrolyst-reading__unit {
font-size:calc(1em * var(--numeric-value-unit-ratio));
font-weight:var(--numeric-unit-weight); color:var(--numeric-unit-color);
}
.petrolyst-surface--context {background:var(--surface-context);}
.petrolyst-surface--live {background:var(--surface-live);}
.petrolyst-divider--y {transform:skewY(var(--y-divider-angle)); transform-origin:left center;}
/* Functional controls require stronger non-text contrast than decorative dividers. */
.petrolyst-control {
border: 1px solid var(--border-control);
}
.petrolyst-divider {
border-color: var(--border-subtle);
}
@@ -0,0 +1,237 @@
/* Petrolyst's sign-in pages. Colours and type come from the brand tokens (css/brand/), light and dark; the form sits on a soft card
beside the dark slate shell, which is the same in both (PD-26), as on the website and in the app. */
@font-face {
font-family: "Inter";
src: url("../fonts/inter-latin-wght-normal.woff2") format("woff2");
font-weight: 100 900;
font-display: swap;
}
/* Fetched at build time and never committed: Satoshi's licence forbids distributing the files (build.sh). Without it, Inter takes over. */
@font-face {
font-family: "Satoshi";
src: url("../fonts/Satoshi-Variable.woff2") format("woff2");
font-weight: 300 900;
font-display: swap;
}
* { box-sizing: border-box; }
html { color-scheme: light; }
body {
margin: 0;
min-height: 100vh;
background: var(--surface-subtle);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-body);
line-height: 1.5;
}
.pl-page {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
max-width: 448px;
margin: 0 auto;
padding: 0 var(--space-6, 24px) var(--space-8, 32px);
}
/* The logo; the realm's name is left out (messages: loginTitleHtml). On a phone a slate band, like the website's header; wide, the panel holds it. */
.pl-header {
display: flex;
align-items: center;
height: 64px;
margin: 0 calc(var(--space-6, 24px) * -1) var(--space-8, 32px);
padding: 0 var(--space-6, 24px);
background: var(--surface-chrome);
border-bottom: 1px solid var(--border-chrome);
}
#kc-header-wrapper {
width: 160px;
height: 22px;
background: url("../img/petrolyst_wordmark_white_y.svg") left center / contain no-repeat;
}
.pl-card {
padding: var(--space-6, 24px);
background: var(--surface-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
}
#kc-page-title {
margin: 0 0 var(--space-6, 24px);
font-family: var(--font-display);
font-size: var(--text-h3);
font-weight: 400;
line-height: 1.2;
}
/* --- The form --- */
.pl-group { margin-bottom: var(--space-5, 20px); }
.pl-label,
#kc-content label {
display: block;
margin-bottom: var(--space-2, 8px);
font-size: var(--text-ui);
font-weight: 600;
}
.pl-input,
#kc-content input[type="text"],
#kc-content input[type="password"],
#kc-content input[type="email"] {
width: 100%;
min-height: 44px;
padding: 10px 14px;
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
background: var(--surface-card);
color: var(--text-primary);
font: inherit;
}
.pl-input:focus,
#kc-content input:focus-visible,
.pl-eye:focus-visible,
.pl-btn:focus-visible,
#kc-content a:focus-visible {
outline: 2px solid var(--action-primary);
outline-offset: 2px;
}
.pl-input[aria-invalid="true"] { border-color: var(--status-critical); }
.pl-input-group { position: relative; }
.pl-input-group .pl-input { padding-right: 48px; }
.pl-eye {
position: absolute;
top: 0;
right: 0;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.pl-eye:hover { color: var(--text-primary); }
.pl-icon {
display: inline-block;
width: 18px;
height: 18px;
background: currentColor;
-webkit-mask: var(--pl-icon) center / contain no-repeat;
mask: var(--pl-icon) center / contain no-repeat;
}
.pl-icon-show { --pl-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E"); }
.pl-icon-hide { --pl-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9.9 4.2A10 10 0 0 1 12 4c6.5 0 10 8 10 8a17 17 0 0 1-2.2 3.3M6.6 6.6A17 17 0 0 0 2 12s3.5 8 10 8a10 10 0 0 0 5.4-1.6M2 2l20 20M9.9 9.9a3 3 0 0 0 4.2 4.2'/%3E%3C/svg%3E"); }
.pl-icon-restart { --pl-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12a9 9 0 1 0 3-6.7L3 8'/%3E%3Cpath d='M3 3v5h5'/%3E%3C/svg%3E"); }
.pl-settings { margin-bottom: 0; font-size: var(--text-ui); }
#kc-form-buttons { margin: var(--space-5, 20px) 0 0; }
.pl-settings a,
#kc-content a {
color: var(--action-primary);
font-weight: 600;
text-decoration: none;
}
#kc-content a:hover { text-decoration: underline; }
#kc-form-options .checkbox label { display: inline-flex; align-items: center; gap: var(--space-2, 8px); font-weight: 400; margin: 0; }
.pl-btn,
#kc-content input[type="submit"] {
min-height: 44px;
padding: 10px 24px;
border: 1px solid var(--border-control);
border-radius: var(--radius-pill);
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: var(--text-ui);
font-weight: 600;
cursor: pointer;
}
.pl-btn-primary,
#kc-content input[type="submit"].pl-btn-primary {
border-color: var(--action-primary);
background: var(--action-primary);
color: var(--action-primary-foreground);
}
.pl-btn-primary:hover { background: var(--action-primary-hover); border-color: var(--action-primary-hover); }
.pl-btn-primary:active { background: var(--action-primary-active); border-color: var(--action-primary-active); }
.pl-btn-primary:disabled { opacity: 0.6; cursor: default; }
.pl-btn-block { width: 100%; }
/* --- Messages --- */
.pl-alert {
display: flex;
gap: var(--space-2, 8px);
margin-bottom: var(--space-5, 20px);
padding: 12px 14px;
border: 1px solid var(--border-default);
border-left-width: 3px;
border-radius: var(--radius-sm);
background: var(--surface-subtle);
font-size: var(--text-ui);
}
.pl-alert.alert-error { border-left-color: var(--status-critical); }
.pl-alert.alert-warning { border-left-color: var(--status-warning, var(--border-strong)); }
.pl-alert.alert-success { border-left-color: var(--action-primary); }
.pl-alert.alert-info { border-left-color: var(--action-primary); }
.pl-field-error {
display: block;
margin-top: var(--space-2, 8px);
color: var(--status-critical);
font-size: var(--text-ui);
}
.instruction,
#kc-content p { color: var(--text-secondary); margin: 0 0 var(--space-5, 20px); }
#kc-content p:last-child { margin-bottom: 0; }
#kc-content ol, #kc-content ul { color: var(--text-secondary); padding-left: 20px; }
#kc-content li { margin-bottom: var(--space-3, 12px); }
#kc-content ul ul { margin-top: var(--space-2, 8px); }
/* The user's name on later steps, with the way back to the start. */
#kc-username { display: flex; align-items: center; justify-content: space-between; margin: 0 0 var(--space-5, 20px); font-size: var(--text-ui); font-weight: 600; }
#kc-attempted-username { margin: 0; }
#reset-login { display: inline-flex; color: var(--text-secondary); }
.kc-tooltip-text { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
#kc-info { margin-top: var(--space-5, 20px); font-size: var(--text-ui); color: var(--text-secondary); }
.subtitle { font-size: var(--text-caption); color: var(--text-muted); }
.required { color: var(--status-critical); }
.clearfix { clear: both; }
/* --- Dark --- */
@media (prefers-color-scheme: dark) {
html { color-scheme: dark; }
}
/* --- Wide: the slate panel with the logo, the form on its card beside it. The logo appears once, so the form's own is hidden. --- */
@media (min-width: 64rem) {
body::before {
content: "";
position: fixed;
inset: 0 50% 0 0;
background:
url("../img/petrolyst_logo_white_y.svg") center / clamp(300px, 30vw, 480px) auto no-repeat,
radial-gradient(ellipse 34vw 40vw at 50% 50%, color-mix(in srgb, var(--petroleum-500) 45%, transparent) 0%, color-mix(in srgb, var(--petroleum-500) 20%, transparent) 45%, transparent 100%),
var(--surface-chrome);
}
.pl-page {
position: relative;
z-index: 1;
justify-content: center;
width: 50%;
max-width: none;
margin: 0 0 0 50%;
padding: var(--space-8, 32px) clamp(48px, 7vw, 128px);
}
.pl-header { display: none; }
.pl-card { padding: var(--space-8, 32px); }
}
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="55 120 1335 700" role="img" aria-label="Petrolyst">
<path d="M 943.0,216.0 L 896.0,216.0 L 895.0,217.0 L 892.0,217.0 L 891.0,216.0 L 880.0,216.0 L 879.0,217.0 L 877.0,217.0 L 840.0,255.0 L 840.0,256.0 L 834.0,261.0 L 834.0,262.0 L 814.0,282.0 L 814.0,283.0 L 794.0,303.0 L 794.0,304.0 L 792.0,306.0 L 791.0,306.0 L 791.0,307.0 L 771.0,327.0 L 771.0,328.0 L 756.0,343.0 L 754.0,347.0 L 751.0,350.0 L 751.0,351.0 L 747.0,356.0 L 746.0,359.0 L 744.0,361.0 L 736.0,377.0 L 736.0,379.0 L 735.0,380.0 L 735.0,382.0 L 732.0,389.0 L 731.0,396.0 L 729.0,400.0 L 729.0,405.0 L 728.0,406.0 L 728.0,414.0 L 727.0,415.0 L 727.0,439.0 L 726.0,440.0 L 726.0,450.0 L 725.0,451.0 L 725.0,456.0 L 724.0,457.0 L 724.0,462.0 L 723.0,463.0 L 723.0,466.0 L 722.0,467.0 L 718.0,481.0 L 716.0,484.0 L 716.0,486.0 L 709.0,500.0 L 707.0,502.0 L 705.0,507.0 L 703.0,509.0 L 700.0,516.0 L 695.0,523.0 L 694.0,527.0 L 691.0,532.0 L 688.0,543.0 L 687.0,544.0 L 686.0,552.0 L 685.0,553.0 L 685.0,559.0 L 684.0,560.0 L 684.0,594.0 L 691.0,594.0 L 692.0,593.0 L 705.0,592.0 L 709.0,590.0 L 715.0,589.0 L 725.0,584.0 L 727.0,582.0 L 730.0,581.0 L 733.0,578.0 L 734.0,578.0 L 741.0,571.0 L 741.0,570.0 L 746.0,564.0 L 750.0,556.0 L 750.0,554.0 L 752.0,550.0 L 752.0,547.0 L 753.0,546.0 L 753.0,533.0 L 754.0,532.0 L 754.0,451.0 L 755.0,450.0 L 755.0,444.0 L 756.0,443.0 L 756.0,439.0 L 757.0,438.0 L 757.0,435.0 L 759.0,431.0 L 759.0,428.0 L 761.0,425.0 L 763.0,418.0 L 768.0,408.0 L 770.0,406.0 L 772.0,401.0 L 774.0,399.0 L 778.0,392.0 L 790.0,379.0 L 790.0,378.0 L 822.0,345.0 L 822.0,344.0 L 868.0,297.0 L 868.0,296.0 L 908.0,255.0 L 908.0,254.0 L 912.0,250.0 L 913.0,250.0 L 915.0,248.0 L 915.0,247.0 L 932.0,230.0 L 932.0,229.0 L 944.0,217.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 505.0,216.0 L 504.0,217.0 L 510.0,223.0 L 510.0,224.0 L 524.0,238.0 L 524.0,239.0 L 538.0,253.0 L 538.0,254.0 L 549.0,265.0 L 549.0,266.0 L 591.0,310.0 L 591.0,311.0 L 598.0,318.0 L 598.0,319.0 L 612.0,333.0 L 612.0,334.0 L 622.0,344.0 L 622.0,345.0 L 630.0,353.0 L 630.0,354.0 L 634.0,357.0 L 634.0,358.0 L 642.0,366.0 L 642.0,367.0 L 653.0,378.0 L 653.0,379.0 L 665.0,393.0 L 668.0,399.0 L 670.0,401.0 L 671.0,404.0 L 673.0,406.0 L 677.0,414.0 L 677.0,416.0 L 680.0,421.0 L 681.0,426.0 L 683.0,429.0 L 683.0,432.0 L 684.0,433.0 L 684.0,436.0 L 685.0,437.0 L 685.0,440.0 L 686.0,441.0 L 687.0,453.0 L 688.0,454.0 L 688.0,489.0 L 689.0,489.0 L 690.0,486.0 L 693.0,483.0 L 696.0,477.0 L 698.0,475.0 L 699.0,472.0 L 701.0,470.0 L 705.0,462.0 L 705.0,460.0 L 707.0,457.0 L 708.0,452.0 L 710.0,449.0 L 712.0,438.0 L 713.0,437.0 L 713.0,433.0 L 714.0,432.0 L 714.0,421.0 L 715.0,420.0 L 715.0,413.0 L 714.0,412.0 L 714.0,402.0 L 713.0,401.0 L 712.0,392.0 L 711.0,391.0 L 711.0,388.0 L 709.0,384.0 L 708.0,378.0 L 699.0,359.0 L 697.0,357.0 L 689.0,344.0 L 677.0,331.0 L 677.0,330.0 L 641.0,292.0 L 641.0,291.0 L 599.0,247.0 L 599.0,246.0 L 587.0,234.0 L 587.0,233.0 L 586.0,233.0 L 584.0,231.0 L 584.0,230.0 L 570.0,216.0 L 563.0,216.0 L 562.0,217.0 L 543.0,217.0 L 542.0,216.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<g fill="#FFFFFF"><path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,715.0 L 250.0,729.0 L 266.0,729.0 L 267.0,730.0 L 301.0,730.0 L 302.0,729.0 L 307.0,729.0 L 308.0,730.0 L 325.0,730.0 L 326.0,729.0 L 340.0,729.0 L 340.0,716.0 L 339.0,715.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1260.0,678.0 L 1260.0,692.0 L 1264.0,692.0 L 1265.0,693.0 L 1296.0,693.0 L 1297.0,694.0 L 1297.0,767.0 L 1313.0,767.0 L 1313.0,694.0 L 1314.0,693.0 L 1351.0,693.0 L 1351.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 867.0,678.0 L 867.0,767.0 L 951.0,767.0 L 951.0,753.0 L 950.0,752.0 L 883.0,752.0 L 882.0,751.0 L 882.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 551.0,678.0 L 551.0,767.0 L 566.0,767.0 L 566.0,743.0 L 567.0,742.0 L 567.0,739.0 L 566.0,738.0 L 567.0,737.0 L 604.0,737.0 L 614.0,747.0 L 614.0,748.0 L 622.0,756.0 L 622.0,757.0 L 630.0,765.0 L 630.0,766.0 L 631.0,767.0 L 651.0,767.0 L 651.0,766.0 L 649.0,764.0 L 649.0,763.0 L 642.0,756.0 L 642.0,755.0 L 628.0,741.0 L 628.0,740.0 L 626.0,738.0 L 628.0,736.0 L 630.0,736.0 L 631.0,735.0 L 633.0,735.0 L 634.0,734.0 L 636.0,734.0 L 638.0,732.0 L 639.0,732.0 L 645.0,726.0 L 645.0,725.0 L 648.0,720.0 L 648.0,718.0 L 649.0,717.0 L 649.0,713.0 L 650.0,712.0 L 650.0,703.0 L 649.0,702.0 L 649.0,698.0 L 648.0,697.0 L 648.0,695.0 L 647.0,694.0 L 646.0,691.0 L 644.0,689.0 L 644.0,688.0 L 640.0,684.0 L 639.0,684.0 L 637.0,682.0 L 636.0,682.0 L 633.0,680.0 L 631.0,680.0 L 630.0,679.0 L 626.0,679.0 L 625.0,678.0 Z M 567.0,694.0 L 568.0,693.0 L 625.0,693.0 L 626.0,694.0 L 629.0,695.0 L 632.0,698.0 L 632.0,699.0 L 633.0,700.0 L 633.0,713.0 L 632.0,714.0 L 632.0,716.0 L 630.0,718.0 L 630.0,719.0 L 629.0,720.0 L 628.0,720.0 L 626.0,722.0 L 622.0,722.0 L 621.0,723.0 L 568.0,723.0 L 566.0,721.0 L 566.0,703.0 L 567.0,702.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 398.0,678.0 L 398.0,693.0 L 434.0,693.0 L 435.0,694.0 L 435.0,767.0 L 451.0,767.0 L 451.0,694.0 L 452.0,693.0 L 489.0,693.0 L 489.0,679.0 L 488.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,678.0 L 250.0,692.0 L 251.0,692.0 L 252.0,693.0 L 339.0,693.0 L 339.0,691.0 L 340.0,690.0 L 340.0,679.0 L 339.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1134.0,683.0 L 1129.0,688.0 L 1129.0,689.0 L 1127.0,691.0 L 1127.0,693.0 L 1126.0,694.0 L 1126.0,696.0 L 1125.0,697.0 L 1125.0,712.0 L 1126.0,713.0 L 1126.0,715.0 L 1127.0,716.0 L 1128.0,719.0 L 1134.0,725.0 L 1135.0,725.0 L 1137.0,727.0 L 1139.0,727.0 L 1140.0,728.0 L 1143.0,728.0 L 1144.0,729.0 L 1185.0,729.0 L 1186.0,730.0 L 1191.0,730.0 L 1192.0,731.0 L 1193.0,731.0 L 1197.0,735.0 L 1197.0,739.0 L 1198.0,740.0 L 1198.0,742.0 L 1197.0,743.0 L 1197.0,746.0 L 1196.0,747.0 L 1196.0,748.0 L 1193.0,751.0 L 1192.0,751.0 L 1189.0,753.0 L 1184.0,753.0 L 1183.0,754.0 L 1151.0,754.0 L 1150.0,753.0 L 1143.0,753.0 L 1142.0,752.0 L 1139.0,752.0 L 1138.0,751.0 L 1135.0,751.0 L 1134.0,750.0 L 1131.0,750.0 L 1130.0,749.0 L 1128.0,749.0 L 1127.0,748.0 L 1125.0,748.0 L 1125.0,760.0 L 1124.0,761.0 L 1126.0,763.0 L 1128.0,763.0 L 1129.0,764.0 L 1131.0,764.0 L 1132.0,765.0 L 1134.0,765.0 L 1135.0,766.0 L 1139.0,766.0 L 1140.0,767.0 L 1146.0,767.0 L 1147.0,768.0 L 1186.0,768.0 L 1187.0,767.0 L 1191.0,767.0 L 1192.0,766.0 L 1195.0,766.0 L 1196.0,765.0 L 1198.0,765.0 L 1199.0,764.0 L 1201.0,764.0 L 1203.0,762.0 L 1204.0,762.0 L 1209.0,757.0 L 1209.0,756.0 L 1211.0,754.0 L 1211.0,753.0 L 1212.0,752.0 L 1212.0,750.0 L 1213.0,749.0 L 1213.0,734.0 L 1212.0,733.0 L 1212.0,730.0 L 1211.0,729.0 L 1211.0,728.0 L 1209.0,726.0 L 1209.0,725.0 L 1204.0,720.0 L 1203.0,720.0 L 1202.0,719.0 L 1201.0,719.0 L 1196.0,716.0 L 1192.0,716.0 L 1191.0,715.0 L 1148.0,715.0 L 1147.0,714.0 L 1145.0,714.0 L 1142.0,711.0 L 1142.0,710.0 L 1141.0,709.0 L 1141.0,699.0 L 1142.0,698.0 L 1142.0,697.0 L 1145.0,694.0 L 1146.0,694.0 L 1147.0,693.0 L 1150.0,693.0 L 1151.0,692.0 L 1190.0,692.0 L 1191.0,693.0 L 1196.0,693.0 L 1197.0,694.0 L 1201.0,694.0 L 1202.0,695.0 L 1204.0,695.0 L 1205.0,696.0 L 1209.0,696.0 L 1209.0,683.0 L 1206.0,681.0 L 1203.0,681.0 L 1202.0,680.0 L 1198.0,680.0 L 1197.0,679.0 L 1193.0,679.0 L 1192.0,678.0 L 1182.0,678.0 L 1181.0,677.0 L 1156.0,677.0 L 1155.0,678.0 L 1146.0,678.0 L 1145.0,679.0 L 1143.0,679.0 L 1142.0,680.0 L 1140.0,680.0 L 1139.0,681.0 L 1138.0,681.0 L 1137.0,682.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 714.0,691.0 L 714.0,692.0 L 711.0,695.0 L 711.0,696.0 L 709.0,698.0 L 709.0,699.0 L 708.0,700.0 L 708.0,702.0 L 707.0,703.0 L 707.0,706.0 L 706.0,707.0 L 706.0,711.0 L 705.0,712.0 L 705.0,734.0 L 706.0,735.0 L 706.0,739.0 L 707.0,740.0 L 707.0,743.0 L 708.0,744.0 L 708.0,745.0 L 709.0,746.0 L 709.0,747.0 L 710.0,748.0 L 711.0,751.0 L 713.0,753.0 L 713.0,754.0 L 718.0,759.0 L 719.0,759.0 L 721.0,761.0 L 722.0,761.0 L 723.0,762.0 L 724.0,762.0 L 729.0,765.0 L 731.0,765.0 L 732.0,766.0 L 735.0,766.0 L 736.0,767.0 L 741.0,767.0 L 742.0,768.0 L 775.0,768.0 L 776.0,767.0 L 781.0,767.0 L 782.0,766.0 L 785.0,766.0 L 786.0,765.0 L 788.0,765.0 L 789.0,764.0 L 790.0,764.0 L 791.0,763.0 L 792.0,763.0 L 793.0,762.0 L 796.0,761.0 L 798.0,759.0 L 799.0,759.0 L 805.0,753.0 L 805.0,752.0 L 807.0,750.0 L 807.0,749.0 L 809.0,746.0 L 809.0,744.0 L 811.0,741.0 L 811.0,737.0 L 812.0,736.0 L 812.0,711.0 L 811.0,710.0 L 811.0,706.0 L 810.0,705.0 L 810.0,703.0 L 809.0,702.0 L 809.0,700.0 L 808.0,699.0 L 808.0,698.0 L 806.0,696.0 L 806.0,695.0 L 803.0,692.0 L 803.0,691.0 L 799.0,687.0 L 798.0,687.0 L 793.0,683.0 L 792.0,683.0 L 791.0,682.0 L 789.0,682.0 L 786.0,680.0 L 783.0,680.0 L 782.0,679.0 L 779.0,679.0 L 778.0,678.0 L 770.0,678.0 L 769.0,677.0 L 748.0,677.0 L 747.0,678.0 L 739.0,678.0 L 738.0,679.0 L 734.0,679.0 L 733.0,680.0 L 731.0,680.0 L 730.0,681.0 L 728.0,681.0 L 727.0,682.0 L 726.0,682.0 L 725.0,683.0 L 722.0,684.0 L 720.0,686.0 L 719.0,686.0 Z M 725.0,702.0 L 730.0,697.0 L 731.0,697.0 L 733.0,695.0 L 735.0,695.0 L 738.0,693.0 L 742.0,693.0 L 743.0,692.0 L 772.0,692.0 L 773.0,693.0 L 777.0,693.0 L 778.0,694.0 L 781.0,694.0 L 782.0,695.0 L 785.0,696.0 L 787.0,698.0 L 788.0,698.0 L 791.0,701.0 L 791.0,702.0 L 793.0,704.0 L 793.0,705.0 L 794.0,706.0 L 794.0,708.0 L 795.0,709.0 L 795.0,712.0 L 796.0,713.0 L 796.0,733.0 L 795.0,734.0 L 795.0,738.0 L 794.0,739.0 L 793.0,742.0 L 791.0,744.0 L 791.0,745.0 L 788.0,748.0 L 787.0,748.0 L 785.0,750.0 L 784.0,750.0 L 781.0,752.0 L 778.0,752.0 L 777.0,753.0 L 770.0,753.0 L 769.0,754.0 L 747.0,754.0 L 746.0,753.0 L 740.0,753.0 L 739.0,752.0 L 737.0,752.0 L 736.0,751.0 L 734.0,751.0 L 733.0,750.0 L 732.0,750.0 L 730.0,748.0 L 729.0,748.0 L 725.0,744.0 L 725.0,743.0 L 722.0,738.0 L 722.0,734.0 L 721.0,733.0 L 721.0,713.0 L 722.0,712.0 L 722.0,709.0 L 723.0,708.0 L 723.0,706.0 L 724.0,705.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 95.0,678.0 L 95.0,766.0 L 96.0,767.0 L 111.0,767.0 L 111.0,737.0 L 112.0,736.0 L 171.0,736.0 L 172.0,735.0 L 175.0,735.0 L 176.0,734.0 L 178.0,734.0 L 179.0,733.0 L 180.0,733.0 L 182.0,731.0 L 183.0,731.0 L 186.0,728.0 L 187.0,728.0 L 187.0,727.0 L 190.0,724.0 L 190.0,723.0 L 193.0,718.0 L 193.0,715.0 L 194.0,714.0 L 194.0,702.0 L 193.0,701.0 L 193.0,696.0 L 192.0,695.0 L 192.0,693.0 L 191.0,692.0 L 190.0,689.0 L 188.0,687.0 L 188.0,686.0 L 185.0,683.0 L 184.0,683.0 L 182.0,681.0 L 181.0,681.0 L 180.0,680.0 L 178.0,680.0 L 177.0,679.0 L 174.0,679.0 L 173.0,678.0 L 168.0,678.0 L 167.0,677.0 L 156.0,677.0 L 155.0,678.0 L 135.0,678.0 L 134.0,677.0 L 132.0,677.0 L 131.0,678.0 L 108.0,678.0 L 107.0,677.0 L 105.0,677.0 L 104.0,678.0 Z M 111.0,693.0 L 112.0,692.0 L 169.0,692.0 L 170.0,693.0 L 172.0,693.0 L 177.0,698.0 L 177.0,699.0 L 178.0,700.0 L 178.0,712.0 L 177.0,713.0 L 177.0,715.0 L 176.0,716.0 L 176.0,717.0 L 174.0,719.0 L 173.0,719.0 L 170.0,721.0 L 167.0,721.0 L 166.0,722.0 L 154.0,722.0 L 153.0,721.0 L 152.0,722.0 L 115.0,722.0 L 114.0,721.0 L 113.0,722.0 L 111.0,720.0 Z" fill="#FFFFFF" fill-rule="evenodd"/><rect x="250" y="678" width="16" height="90" fill="#FFFFFF"/></g>
<path d="M 1084.0,678.0 L 1063.0,678.0 L 1056.0,685.0 L 1056.0,686.0 L 1049.0,693.0 L 1049.0,694.0 L 1042.0,701.0 L 1042.0,702.0 L 1036.0,708.0 L 1036.0,709.0 L 1035.0,710.0 L 1035.0,711.0 L 1033.0,713.0 L 1033.0,714.0 L 1032.0,715.0 L 1032.0,716.0 L 1031.0,717.0 L 1031.0,718.0 L 1030.0,719.0 L 1030.0,720.0 L 1028.0,723.0 L 1028.0,725.0 L 1026.0,728.0 L 1026.0,730.0 L 1025.0,731.0 L 1025.0,733.0 L 1024.0,734.0 L 1024.0,738.0 L 1023.0,739.0 L 1023.0,743.0 L 1022.0,744.0 L 1022.0,752.0 L 1021.0,753.0 L 1021.0,766.0 L 1022.0,767.0 L 1038.0,767.0 L 1038.0,737.0 L 1039.0,736.0 L 1039.0,734.0 L 1040.0,733.0 L 1040.0,732.0 L 1041.0,731.0 L 1041.0,730.0 L 1043.0,728.0 L 1043.0,727.0 L 1046.0,724.0 L 1046.0,723.0 L 1052.0,717.0 L 1052.0,716.0 L 1060.0,708.0 L 1060.0,707.0 L 1065.0,702.0 L 1065.0,701.0 L 1072.0,694.0 L 1072.0,693.0 L 1077.0,688.0 L 1077.0,687.0 L 1082.0,682.0 L 1082.0,681.0 L 1084.0,679.0 L 1085.0,679.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 975.0,678.0 L 975.0,679.0 L 979.0,683.0 L 979.0,684.0 L 983.0,688.0 L 983.0,689.0 L 990.0,696.0 L 990.0,697.0 L 996.0,703.0 L 996.0,704.0 L 1002.0,710.0 L 1002.0,711.0 L 1010.0,719.0 L 1010.0,720.0 L 1014.0,724.0 L 1014.0,725.0 L 1017.0,728.0 L 1017.0,729.0 L 1018.0,730.0 L 1018.0,731.0 L 1019.0,732.0 L 1020.0,732.0 L 1020.0,729.0 L 1021.0,728.0 L 1021.0,726.0 L 1022.0,725.0 L 1022.0,722.0 L 1023.0,721.0 L 1023.0,715.0 L 1024.0,714.0 L 1023.0,713.0 L 1023.0,709.0 L 1022.0,708.0 L 1022.0,707.0 L 1021.0,706.0 L 1021.0,705.0 L 1016.0,700.0 L 1016.0,699.0 L 1010.0,693.0 L 1010.0,692.0 L 1003.0,685.0 L 1003.0,684.0 L 997.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,60 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1448 1086" role="img" aria-label="Petrolyst">
<!-- Petrolyst's logo with its line, Fuel Intelligence Platform: petrolyst_logo_primary_fullcolor.svg with the letters white for the dark chrome (PD-26); the Y keeps its original gradient. -->
<defs>
<clipPath id="markClip"><path d="M 943.0,216.0 L 896.0,216.0 L 895.0,217.0 L 892.0,217.0 L 891.0,216.0 L 880.0,216.0 L 879.0,217.0 L 877.0,217.0 L 840.0,255.0 L 840.0,256.0 L 834.0,261.0 L 834.0,262.0 L 814.0,282.0 L 814.0,283.0 L 794.0,303.0 L 794.0,304.0 L 792.0,306.0 L 791.0,306.0 L 791.0,307.0 L 771.0,327.0 L 771.0,328.0 L 756.0,343.0 L 754.0,347.0 L 751.0,350.0 L 751.0,351.0 L 747.0,356.0 L 746.0,359.0 L 744.0,361.0 L 736.0,377.0 L 736.0,379.0 L 735.0,380.0 L 735.0,382.0 L 732.0,389.0 L 731.0,396.0 L 729.0,400.0 L 729.0,405.0 L 728.0,406.0 L 728.0,414.0 L 727.0,415.0 L 727.0,439.0 L 726.0,440.0 L 726.0,450.0 L 725.0,451.0 L 725.0,456.0 L 724.0,457.0 L 724.0,462.0 L 723.0,463.0 L 723.0,466.0 L 722.0,467.0 L 718.0,481.0 L 716.0,484.0 L 716.0,486.0 L 709.0,500.0 L 707.0,502.0 L 705.0,507.0 L 703.0,509.0 L 700.0,516.0 L 695.0,523.0 L 694.0,527.0 L 691.0,532.0 L 688.0,543.0 L 687.0,544.0 L 686.0,552.0 L 685.0,553.0 L 685.0,559.0 L 684.0,560.0 L 684.0,594.0 L 691.0,594.0 L 692.0,593.0 L 705.0,592.0 L 709.0,590.0 L 715.0,589.0 L 725.0,584.0 L 727.0,582.0 L 730.0,581.0 L 733.0,578.0 L 734.0,578.0 L 741.0,571.0 L 741.0,570.0 L 746.0,564.0 L 750.0,556.0 L 750.0,554.0 L 752.0,550.0 L 752.0,547.0 L 753.0,546.0 L 753.0,533.0 L 754.0,532.0 L 754.0,451.0 L 755.0,450.0 L 755.0,444.0 L 756.0,443.0 L 756.0,439.0 L 757.0,438.0 L 757.0,435.0 L 759.0,431.0 L 759.0,428.0 L 761.0,425.0 L 763.0,418.0 L 768.0,408.0 L 770.0,406.0 L 772.0,401.0 L 774.0,399.0 L 778.0,392.0 L 790.0,379.0 L 790.0,378.0 L 822.0,345.0 L 822.0,344.0 L 868.0,297.0 L 868.0,296.0 L 908.0,255.0 L 908.0,254.0 L 912.0,250.0 L 913.0,250.0 L 915.0,248.0 L 915.0,247.0 L 932.0,230.0 L 932.0,229.0 L 944.0,217.0 Z" fill="#000" fill-rule="evenodd"/>
<path d="M 505.0,216.0 L 504.0,217.0 L 510.0,223.0 L 510.0,224.0 L 524.0,238.0 L 524.0,239.0 L 538.0,253.0 L 538.0,254.0 L 549.0,265.0 L 549.0,266.0 L 591.0,310.0 L 591.0,311.0 L 598.0,318.0 L 598.0,319.0 L 612.0,333.0 L 612.0,334.0 L 622.0,344.0 L 622.0,345.0 L 630.0,353.0 L 630.0,354.0 L 634.0,357.0 L 634.0,358.0 L 642.0,366.0 L 642.0,367.0 L 653.0,378.0 L 653.0,379.0 L 665.0,393.0 L 668.0,399.0 L 670.0,401.0 L 671.0,404.0 L 673.0,406.0 L 677.0,414.0 L 677.0,416.0 L 680.0,421.0 L 681.0,426.0 L 683.0,429.0 L 683.0,432.0 L 684.0,433.0 L 684.0,436.0 L 685.0,437.0 L 685.0,440.0 L 686.0,441.0 L 687.0,453.0 L 688.0,454.0 L 688.0,489.0 L 689.0,489.0 L 690.0,486.0 L 693.0,483.0 L 696.0,477.0 L 698.0,475.0 L 699.0,472.0 L 701.0,470.0 L 705.0,462.0 L 705.0,460.0 L 707.0,457.0 L 708.0,452.0 L 710.0,449.0 L 712.0,438.0 L 713.0,437.0 L 713.0,433.0 L 714.0,432.0 L 714.0,421.0 L 715.0,420.0 L 715.0,413.0 L 714.0,412.0 L 714.0,402.0 L 713.0,401.0 L 712.0,392.0 L 711.0,391.0 L 711.0,388.0 L 709.0,384.0 L 708.0,378.0 L 699.0,359.0 L 697.0,357.0 L 689.0,344.0 L 677.0,331.0 L 677.0,330.0 L 641.0,292.0 L 641.0,291.0 L 599.0,247.0 L 599.0,246.0 L 587.0,234.0 L 587.0,233.0 L 586.0,233.0 L 584.0,231.0 L 584.0,230.0 L 570.0,216.0 L 563.0,216.0 L 562.0,217.0 L 543.0,217.0 L 542.0,216.0 Z" fill="#000" fill-rule="evenodd"/></clipPath>
<linearGradient id="rightGradient" x1="710" y1="180" x2="965" y2="590" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#009688"/>
<stop offset="100%" stop-color="#00B6E6"/>
</linearGradient>
<linearGradient id="wordYGradient" x1="945" y1="660" x2="1090" y2="780" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#009688"/>
<stop offset="100%" stop-color="#00B6E6"/>
</linearGradient>
</defs>
<g clip-path="url(#markClip)">
<rect x="0" y="0" width="735" height="1086" fill="#FFFFFF"/>
<rect x="735" y="0" width="665" height="1086" fill="url(#rightGradient)"/>
</g>
<g fill="#FFFFFF">
<path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,715.0 L 250.0,729.0 L 266.0,729.0 L 267.0,730.0 L 301.0,730.0 L 302.0,729.0 L 307.0,729.0 L 308.0,730.0 L 325.0,730.0 L 326.0,729.0 L 340.0,729.0 L 340.0,716.0 L 339.0,715.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1260.0,678.0 L 1260.0,692.0 L 1264.0,692.0 L 1265.0,693.0 L 1296.0,693.0 L 1297.0,694.0 L 1297.0,767.0 L 1313.0,767.0 L 1313.0,694.0 L 1314.0,693.0 L 1351.0,693.0 L 1351.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 867.0,678.0 L 867.0,767.0 L 951.0,767.0 L 951.0,753.0 L 950.0,752.0 L 883.0,752.0 L 882.0,751.0 L 882.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 551.0,678.0 L 551.0,767.0 L 566.0,767.0 L 566.0,743.0 L 567.0,742.0 L 567.0,739.0 L 566.0,738.0 L 567.0,737.0 L 604.0,737.0 L 614.0,747.0 L 614.0,748.0 L 622.0,756.0 L 622.0,757.0 L 630.0,765.0 L 630.0,766.0 L 631.0,767.0 L 651.0,767.0 L 651.0,766.0 L 649.0,764.0 L 649.0,763.0 L 642.0,756.0 L 642.0,755.0 L 628.0,741.0 L 628.0,740.0 L 626.0,738.0 L 628.0,736.0 L 630.0,736.0 L 631.0,735.0 L 633.0,735.0 L 634.0,734.0 L 636.0,734.0 L 638.0,732.0 L 639.0,732.0 L 645.0,726.0 L 645.0,725.0 L 648.0,720.0 L 648.0,718.0 L 649.0,717.0 L 649.0,713.0 L 650.0,712.0 L 650.0,703.0 L 649.0,702.0 L 649.0,698.0 L 648.0,697.0 L 648.0,695.0 L 647.0,694.0 L 646.0,691.0 L 644.0,689.0 L 644.0,688.0 L 640.0,684.0 L 639.0,684.0 L 637.0,682.0 L 636.0,682.0 L 633.0,680.0 L 631.0,680.0 L 630.0,679.0 L 626.0,679.0 L 625.0,678.0 Z M 567.0,694.0 L 568.0,693.0 L 625.0,693.0 L 626.0,694.0 L 629.0,695.0 L 632.0,698.0 L 632.0,699.0 L 633.0,700.0 L 633.0,713.0 L 632.0,714.0 L 632.0,716.0 L 630.0,718.0 L 630.0,719.0 L 629.0,720.0 L 628.0,720.0 L 626.0,722.0 L 622.0,722.0 L 621.0,723.0 L 568.0,723.0 L 566.0,721.0 L 566.0,703.0 L 567.0,702.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 398.0,678.0 L 398.0,693.0 L 434.0,693.0 L 435.0,694.0 L 435.0,767.0 L 451.0,767.0 L 451.0,694.0 L 452.0,693.0 L 489.0,693.0 L 489.0,679.0 L 488.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,678.0 L 250.0,692.0 L 251.0,692.0 L 252.0,693.0 L 339.0,693.0 L 339.0,691.0 L 340.0,690.0 L 340.0,679.0 L 339.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1134.0,683.0 L 1129.0,688.0 L 1129.0,689.0 L 1127.0,691.0 L 1127.0,693.0 L 1126.0,694.0 L 1126.0,696.0 L 1125.0,697.0 L 1125.0,712.0 L 1126.0,713.0 L 1126.0,715.0 L 1127.0,716.0 L 1128.0,719.0 L 1134.0,725.0 L 1135.0,725.0 L 1137.0,727.0 L 1139.0,727.0 L 1140.0,728.0 L 1143.0,728.0 L 1144.0,729.0 L 1185.0,729.0 L 1186.0,730.0 L 1191.0,730.0 L 1192.0,731.0 L 1193.0,731.0 L 1197.0,735.0 L 1197.0,739.0 L 1198.0,740.0 L 1198.0,742.0 L 1197.0,743.0 L 1197.0,746.0 L 1196.0,747.0 L 1196.0,748.0 L 1193.0,751.0 L 1192.0,751.0 L 1189.0,753.0 L 1184.0,753.0 L 1183.0,754.0 L 1151.0,754.0 L 1150.0,753.0 L 1143.0,753.0 L 1142.0,752.0 L 1139.0,752.0 L 1138.0,751.0 L 1135.0,751.0 L 1134.0,750.0 L 1131.0,750.0 L 1130.0,749.0 L 1128.0,749.0 L 1127.0,748.0 L 1125.0,748.0 L 1125.0,760.0 L 1124.0,761.0 L 1126.0,763.0 L 1128.0,763.0 L 1129.0,764.0 L 1131.0,764.0 L 1132.0,765.0 L 1134.0,765.0 L 1135.0,766.0 L 1139.0,766.0 L 1140.0,767.0 L 1146.0,767.0 L 1147.0,768.0 L 1186.0,768.0 L 1187.0,767.0 L 1191.0,767.0 L 1192.0,766.0 L 1195.0,766.0 L 1196.0,765.0 L 1198.0,765.0 L 1199.0,764.0 L 1201.0,764.0 L 1203.0,762.0 L 1204.0,762.0 L 1209.0,757.0 L 1209.0,756.0 L 1211.0,754.0 L 1211.0,753.0 L 1212.0,752.0 L 1212.0,750.0 L 1213.0,749.0 L 1213.0,734.0 L 1212.0,733.0 L 1212.0,730.0 L 1211.0,729.0 L 1211.0,728.0 L 1209.0,726.0 L 1209.0,725.0 L 1204.0,720.0 L 1203.0,720.0 L 1202.0,719.0 L 1201.0,719.0 L 1196.0,716.0 L 1192.0,716.0 L 1191.0,715.0 L 1148.0,715.0 L 1147.0,714.0 L 1145.0,714.0 L 1142.0,711.0 L 1142.0,710.0 L 1141.0,709.0 L 1141.0,699.0 L 1142.0,698.0 L 1142.0,697.0 L 1145.0,694.0 L 1146.0,694.0 L 1147.0,693.0 L 1150.0,693.0 L 1151.0,692.0 L 1190.0,692.0 L 1191.0,693.0 L 1196.0,693.0 L 1197.0,694.0 L 1201.0,694.0 L 1202.0,695.0 L 1204.0,695.0 L 1205.0,696.0 L 1209.0,696.0 L 1209.0,683.0 L 1206.0,681.0 L 1203.0,681.0 L 1202.0,680.0 L 1198.0,680.0 L 1197.0,679.0 L 1193.0,679.0 L 1192.0,678.0 L 1182.0,678.0 L 1181.0,677.0 L 1156.0,677.0 L 1155.0,678.0 L 1146.0,678.0 L 1145.0,679.0 L 1143.0,679.0 L 1142.0,680.0 L 1140.0,680.0 L 1139.0,681.0 L 1138.0,681.0 L 1137.0,682.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 714.0,691.0 L 714.0,692.0 L 711.0,695.0 L 711.0,696.0 L 709.0,698.0 L 709.0,699.0 L 708.0,700.0 L 708.0,702.0 L 707.0,703.0 L 707.0,706.0 L 706.0,707.0 L 706.0,711.0 L 705.0,712.0 L 705.0,734.0 L 706.0,735.0 L 706.0,739.0 L 707.0,740.0 L 707.0,743.0 L 708.0,744.0 L 708.0,745.0 L 709.0,746.0 L 709.0,747.0 L 710.0,748.0 L 711.0,751.0 L 713.0,753.0 L 713.0,754.0 L 718.0,759.0 L 719.0,759.0 L 721.0,761.0 L 722.0,761.0 L 723.0,762.0 L 724.0,762.0 L 729.0,765.0 L 731.0,765.0 L 732.0,766.0 L 735.0,766.0 L 736.0,767.0 L 741.0,767.0 L 742.0,768.0 L 775.0,768.0 L 776.0,767.0 L 781.0,767.0 L 782.0,766.0 L 785.0,766.0 L 786.0,765.0 L 788.0,765.0 L 789.0,764.0 L 790.0,764.0 L 791.0,763.0 L 792.0,763.0 L 793.0,762.0 L 796.0,761.0 L 798.0,759.0 L 799.0,759.0 L 805.0,753.0 L 805.0,752.0 L 807.0,750.0 L 807.0,749.0 L 809.0,746.0 L 809.0,744.0 L 811.0,741.0 L 811.0,737.0 L 812.0,736.0 L 812.0,711.0 L 811.0,710.0 L 811.0,706.0 L 810.0,705.0 L 810.0,703.0 L 809.0,702.0 L 809.0,700.0 L 808.0,699.0 L 808.0,698.0 L 806.0,696.0 L 806.0,695.0 L 803.0,692.0 L 803.0,691.0 L 799.0,687.0 L 798.0,687.0 L 793.0,683.0 L 792.0,683.0 L 791.0,682.0 L 789.0,682.0 L 786.0,680.0 L 783.0,680.0 L 782.0,679.0 L 779.0,679.0 L 778.0,678.0 L 770.0,678.0 L 769.0,677.0 L 748.0,677.0 L 747.0,678.0 L 739.0,678.0 L 738.0,679.0 L 734.0,679.0 L 733.0,680.0 L 731.0,680.0 L 730.0,681.0 L 728.0,681.0 L 727.0,682.0 L 726.0,682.0 L 725.0,683.0 L 722.0,684.0 L 720.0,686.0 L 719.0,686.0 Z M 725.0,702.0 L 730.0,697.0 L 731.0,697.0 L 733.0,695.0 L 735.0,695.0 L 738.0,693.0 L 742.0,693.0 L 743.0,692.0 L 772.0,692.0 L 773.0,693.0 L 777.0,693.0 L 778.0,694.0 L 781.0,694.0 L 782.0,695.0 L 785.0,696.0 L 787.0,698.0 L 788.0,698.0 L 791.0,701.0 L 791.0,702.0 L 793.0,704.0 L 793.0,705.0 L 794.0,706.0 L 794.0,708.0 L 795.0,709.0 L 795.0,712.0 L 796.0,713.0 L 796.0,733.0 L 795.0,734.0 L 795.0,738.0 L 794.0,739.0 L 793.0,742.0 L 791.0,744.0 L 791.0,745.0 L 788.0,748.0 L 787.0,748.0 L 785.0,750.0 L 784.0,750.0 L 781.0,752.0 L 778.0,752.0 L 777.0,753.0 L 770.0,753.0 L 769.0,754.0 L 747.0,754.0 L 746.0,753.0 L 740.0,753.0 L 739.0,752.0 L 737.0,752.0 L 736.0,751.0 L 734.0,751.0 L 733.0,750.0 L 732.0,750.0 L 730.0,748.0 L 729.0,748.0 L 725.0,744.0 L 725.0,743.0 L 722.0,738.0 L 722.0,734.0 L 721.0,733.0 L 721.0,713.0 L 722.0,712.0 L 722.0,709.0 L 723.0,708.0 L 723.0,706.0 L 724.0,705.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 95.0,678.0 L 95.0,766.0 L 96.0,767.0 L 111.0,767.0 L 111.0,737.0 L 112.0,736.0 L 171.0,736.0 L 172.0,735.0 L 175.0,735.0 L 176.0,734.0 L 178.0,734.0 L 179.0,733.0 L 180.0,733.0 L 182.0,731.0 L 183.0,731.0 L 186.0,728.0 L 187.0,728.0 L 187.0,727.0 L 190.0,724.0 L 190.0,723.0 L 193.0,718.0 L 193.0,715.0 L 194.0,714.0 L 194.0,702.0 L 193.0,701.0 L 193.0,696.0 L 192.0,695.0 L 192.0,693.0 L 191.0,692.0 L 190.0,689.0 L 188.0,687.0 L 188.0,686.0 L 185.0,683.0 L 184.0,683.0 L 182.0,681.0 L 181.0,681.0 L 180.0,680.0 L 178.0,680.0 L 177.0,679.0 L 174.0,679.0 L 173.0,678.0 L 168.0,678.0 L 167.0,677.0 L 156.0,677.0 L 155.0,678.0 L 135.0,678.0 L 134.0,677.0 L 132.0,677.0 L 131.0,678.0 L 108.0,678.0 L 107.0,677.0 L 105.0,677.0 L 104.0,678.0 Z M 111.0,693.0 L 112.0,692.0 L 169.0,692.0 L 170.0,693.0 L 172.0,693.0 L 177.0,698.0 L 177.0,699.0 L 178.0,700.0 L 178.0,712.0 L 177.0,713.0 L 177.0,715.0 L 176.0,716.0 L 176.0,717.0 L 174.0,719.0 L 173.0,719.0 L 170.0,721.0 L 167.0,721.0 L 166.0,722.0 L 154.0,722.0 L 153.0,721.0 L 152.0,722.0 L 115.0,722.0 L 114.0,721.0 L 113.0,722.0 L 111.0,720.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<rect x="250" y="678" width="16" height="90" fill="#FFFFFF"/>
</g>
<path d="M 1084.0,678.0 L 1063.0,678.0 L 1056.0,685.0 L 1056.0,686.0 L 1049.0,693.0 L 1049.0,694.0 L 1042.0,701.0 L 1042.0,702.0 L 1036.0,708.0 L 1036.0,709.0 L 1035.0,710.0 L 1035.0,711.0 L 1033.0,713.0 L 1033.0,714.0 L 1032.0,715.0 L 1032.0,716.0 L 1031.0,717.0 L 1031.0,718.0 L 1030.0,719.0 L 1030.0,720.0 L 1028.0,723.0 L 1028.0,725.0 L 1026.0,728.0 L 1026.0,730.0 L 1025.0,731.0 L 1025.0,733.0 L 1024.0,734.0 L 1024.0,738.0 L 1023.0,739.0 L 1023.0,743.0 L 1022.0,744.0 L 1022.0,752.0 L 1021.0,753.0 L 1021.0,766.0 L 1022.0,767.0 L 1038.0,767.0 L 1038.0,737.0 L 1039.0,736.0 L 1039.0,734.0 L 1040.0,733.0 L 1040.0,732.0 L 1041.0,731.0 L 1041.0,730.0 L 1043.0,728.0 L 1043.0,727.0 L 1046.0,724.0 L 1046.0,723.0 L 1052.0,717.0 L 1052.0,716.0 L 1060.0,708.0 L 1060.0,707.0 L 1065.0,702.0 L 1065.0,701.0 L 1072.0,694.0 L 1072.0,693.0 L 1077.0,688.0 L 1077.0,687.0 L 1082.0,682.0 L 1082.0,681.0 L 1084.0,679.0 L 1085.0,679.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
<path d="M 975.0,678.0 L 975.0,679.0 L 979.0,683.0 L 979.0,684.0 L 983.0,688.0 L 983.0,689.0 L 990.0,696.0 L 990.0,697.0 L 996.0,703.0 L 996.0,704.0 L 1002.0,710.0 L 1002.0,711.0 L 1010.0,719.0 L 1010.0,720.0 L 1014.0,724.0 L 1014.0,725.0 L 1017.0,728.0 L 1017.0,729.0 L 1018.0,730.0 L 1018.0,731.0 L 1019.0,732.0 L 1020.0,732.0 L 1020.0,729.0 L 1021.0,728.0 L 1021.0,726.0 L 1022.0,725.0 L 1022.0,722.0 L 1023.0,721.0 L 1023.0,715.0 L 1024.0,714.0 L 1023.0,713.0 L 1023.0,709.0 L 1022.0,708.0 L 1022.0,707.0 L 1021.0,706.0 L 1021.0,705.0 L 1016.0,700.0 L 1016.0,699.0 L 1010.0,693.0 L 1010.0,692.0 L 1003.0,685.0 L 1003.0,684.0 L 997.0,678.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
<path d="M 1189.0,814.0 L 1189.0,837.0 L 1193.0,837.0 L 1193.0,826.0 L 1194.0,825.0 L 1195.0,826.0 L 1195.0,827.0 L 1197.0,829.0 L 1197.0,830.0 L 1198.0,831.0 L 1198.0,833.0 L 1200.0,835.0 L 1203.0,835.0 L 1203.0,834.0 L 1204.0,833.0 L 1204.0,832.0 L 1205.0,831.0 L 1205.0,830.0 L 1206.0,829.0 L 1206.0,828.0 L 1207.0,827.0 L 1207.0,826.0 L 1208.0,825.0 L 1209.0,826.0 L 1209.0,827.0 L 1210.0,828.0 L 1210.0,837.0 L 1214.0,837.0 L 1214.0,814.0 L 1209.0,814.0 L 1208.0,815.0 L 1208.0,816.0 L 1207.0,817.0 L 1207.0,818.0 L 1206.0,819.0 L 1206.0,820.0 L 1205.0,821.0 L 1205.0,822.0 L 1204.0,823.0 L 1204.0,824.0 L 1203.0,825.0 L 1203.0,826.0 L 1201.0,828.0 L 1200.0,827.0 L 1200.0,826.0 L 1199.0,825.0 L 1199.0,824.0 L 1198.0,823.0 L 1198.0,822.0 L 1197.0,821.0 L 1197.0,820.0 L 1196.0,819.0 L 1196.0,818.0 L 1195.0,817.0 L 1195.0,815.0 L 1194.0,814.0 Z M 1208.0,825.0 L 1209.0,824.0 L 1210.0,825.0 L 1209.0,826.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1148.0,814.0 L 1148.0,837.0 L 1152.0,837.0 L 1152.0,830.0 L 1153.0,829.0 L 1157.0,829.0 L 1158.0,830.0 L 1158.0,831.0 L 1159.0,832.0 L 1159.0,833.0 L 1161.0,835.0 L 1161.0,836.0 L 1162.0,837.0 L 1163.0,837.0 L 1164.0,838.0 L 1165.0,837.0 L 1167.0,837.0 L 1167.0,836.0 L 1165.0,834.0 L 1165.0,833.0 L 1163.0,831.0 L 1163.0,830.0 L 1162.0,829.0 L 1162.0,828.0 L 1163.0,827.0 L 1164.0,827.0 L 1165.0,826.0 L 1165.0,825.0 L 1166.0,824.0 L 1166.0,818.0 L 1165.0,817.0 L 1165.0,816.0 L 1164.0,816.0 L 1163.0,815.0 L 1162.0,815.0 L 1161.0,814.0 Z M 1152.0,819.0 L 1153.0,818.0 L 1160.0,818.0 L 1162.0,820.0 L 1162.0,823.0 L 1160.0,825.0 L 1153.0,825.0 L 1152.0,824.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1109.0,814.0 L 1108.0,815.0 L 1107.0,815.0 L 1103.0,819.0 L 1103.0,820.0 L 1102.0,821.0 L 1102.0,823.0 L 1101.0,824.0 L 1101.0,828.0 L 1102.0,829.0 L 1102.0,831.0 L 1103.0,832.0 L 1103.0,833.0 L 1106.0,836.0 L 1107.0,836.0 L 1108.0,837.0 L 1109.0,837.0 L 1110.0,838.0 L 1117.0,838.0 L 1118.0,837.0 L 1119.0,837.0 L 1121.0,835.0 L 1122.0,835.0 L 1122.0,834.0 L 1124.0,832.0 L 1124.0,831.0 L 1125.0,830.0 L 1125.0,821.0 L 1124.0,820.0 L 1124.0,819.0 L 1121.0,816.0 L 1120.0,816.0 L 1119.0,815.0 L 1118.0,815.0 L 1117.0,814.0 Z M 1110.0,818.0 L 1116.0,818.0 L 1117.0,819.0 L 1118.0,819.0 L 1120.0,821.0 L 1120.0,823.0 L 1121.0,824.0 L 1121.0,828.0 L 1120.0,829.0 L 1120.0,830.0 L 1117.0,833.0 L 1114.0,833.0 L 1113.0,834.0 L 1112.0,833.0 L 1110.0,833.0 L 1109.0,832.0 L 1108.0,832.0 L 1107.0,831.0 L 1107.0,830.0 L 1106.0,829.0 L 1106.0,822.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1081.0,814.0 L 1065.0,814.0 L 1065.0,837.0 L 1069.0,837.0 L 1069.0,829.0 L 1070.0,828.0 L 1079.0,828.0 L 1080.0,827.0 L 1080.0,825.0 L 1079.0,824.0 L 1070.0,824.0 L 1069.0,823.0 L 1069.0,819.0 L 1070.0,818.0 L 1080.0,818.0 L 1081.0,817.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1043.0,814.0 L 1025.0,814.0 L 1025.0,818.0 L 1031.0,818.0 L 1032.0,819.0 L 1032.0,837.0 L 1033.0,838.0 L 1034.0,838.0 L 1035.0,837.0 L 1036.0,837.0 L 1036.0,819.0 L 1037.0,818.0 L 1043.0,818.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 994.0,814.0 L 993.0,815.0 L 993.0,816.0 L 992.0,817.0 L 992.0,819.0 L 991.0,820.0 L 991.0,821.0 L 990.0,822.0 L 990.0,823.0 L 989.0,824.0 L 989.0,826.0 L 988.0,827.0 L 988.0,828.0 L 987.0,829.0 L 987.0,831.0 L 986.0,832.0 L 986.0,833.0 L 985.0,834.0 L 985.0,835.0 L 984.0,836.0 L 984.0,837.0 L 989.0,837.0 L 989.0,835.0 L 990.0,834.0 L 990.0,833.0 L 991.0,832.0 L 1000.0,832.0 L 1002.0,834.0 L 1002.0,836.0 L 1003.0,837.0 L 1008.0,837.0 L 1007.0,836.0 L 1007.0,835.0 L 1006.0,834.0 L 1006.0,832.0 L 1005.0,831.0 L 1005.0,830.0 L 1004.0,829.0 L 1004.0,828.0 L 1003.0,827.0 L 1003.0,825.0 L 1002.0,824.0 L 1002.0,823.0 L 1001.0,822.0 L 1001.0,821.0 L 1000.0,820.0 L 1000.0,819.0 L 999.0,818.0 L 999.0,816.0 L 997.0,814.0 Z M 996.0,820.0 L 997.0,821.0 L 997.0,823.0 L 998.0,824.0 L 998.0,825.0 L 999.0,826.0 L 999.0,827.0 L 998.0,828.0 L 994.0,828.0 L 993.0,827.0 L 993.0,825.0 L 994.0,824.0 L 994.0,823.0 L 995.0,822.0 L 995.0,821.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 950.0,814.0 L 950.0,837.0 L 956.0,837.0 L 957.0,838.0 L 959.0,838.0 L 960.0,837.0 L 965.0,837.0 L 965.0,834.0 L 955.0,834.0 L 954.0,833.0 L 954.0,815.0 L 953.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 923.0,814.0 L 911.0,814.0 L 910.0,815.0 L 910.0,836.0 L 911.0,837.0 L 914.0,837.0 L 914.0,832.0 L 915.0,831.0 L 915.0,830.0 L 916.0,829.0 L 924.0,829.0 L 928.0,825.0 L 928.0,819.0 L 927.0,818.0 L 927.0,817.0 L 925.0,815.0 L 924.0,815.0 Z M 914.0,819.0 L 915.0,818.0 L 922.0,818.0 L 924.0,820.0 L 924.0,824.0 L 923.0,825.0 L 922.0,825.0 L 921.0,826.0 L 915.0,826.0 L 914.0,825.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 841.0,814.0 L 841.0,837.0 L 842.0,838.0 L 850.0,838.0 L 851.0,837.0 L 858.0,837.0 L 858.0,834.0 L 856.0,834.0 L 855.0,833.0 L 847.0,833.0 L 846.0,832.0 L 846.0,829.0 L 847.0,828.0 L 856.0,828.0 L 857.0,827.0 L 857.0,824.0 L 856.0,823.0 L 847.0,823.0 L 846.0,822.0 L 846.0,819.0 L 847.0,818.0 L 848.0,819.0 L 848.0,821.0 L 849.0,821.0 L 848.0,820.0 L 848.0,819.0 L 849.0,818.0 L 858.0,818.0 L 858.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 753.0,814.0 L 752.0,815.0 L 752.0,837.0 L 757.0,837.0 L 757.0,824.0 L 758.0,823.0 L 760.0,825.0 L 760.0,826.0 L 763.0,829.0 L 763.0,830.0 L 765.0,832.0 L 765.0,833.0 L 768.0,836.0 L 768.0,837.0 L 773.0,837.0 L 773.0,814.0 L 769.0,814.0 L 769.0,827.0 L 768.0,828.0 L 766.0,826.0 L 766.0,825.0 L 763.0,822.0 L 763.0,821.0 L 759.0,817.0 L 759.0,816.0 L 757.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 713.0,814.0 L 713.0,836.0 L 712.0,837.0 L 713.0,838.0 L 721.0,838.0 L 722.0,837.0 L 730.0,837.0 L 730.0,834.0 L 725.0,834.0 L 724.0,833.0 L 718.0,833.0 L 717.0,832.0 L 717.0,829.0 L 718.0,828.0 L 727.0,828.0 L 728.0,827.0 L 728.0,824.0 L 727.0,823.0 L 718.0,823.0 L 717.0,822.0 L 718.0,821.0 L 719.0,821.0 L 718.0,821.0 L 717.0,820.0 L 717.0,819.0 L 718.0,818.0 L 729.0,818.0 L 729.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 668.0,819.0 L 668.0,820.0 L 667.0,821.0 L 667.0,830.0 L 668.0,831.0 L 668.0,832.0 L 669.0,833.0 L 669.0,834.0 L 670.0,835.0 L 671.0,835.0 L 673.0,837.0 L 675.0,837.0 L 676.0,838.0 L 682.0,838.0 L 683.0,837.0 L 685.0,837.0 L 687.0,835.0 L 689.0,835.0 L 689.0,825.0 L 688.0,824.0 L 687.0,824.0 L 686.0,825.0 L 682.0,825.0 L 681.0,824.0 L 680.0,825.0 L 680.0,828.0 L 684.0,828.0 L 685.0,829.0 L 685.0,831.0 L 683.0,833.0 L 681.0,833.0 L 680.0,834.0 L 678.0,834.0 L 677.0,833.0 L 675.0,833.0 L 672.0,830.0 L 672.0,828.0 L 671.0,827.0 L 671.0,825.0 L 672.0,824.0 L 672.0,822.0 L 676.0,818.0 L 682.0,818.0 L 684.0,820.0 L 686.0,820.0 L 687.0,819.0 L 688.0,819.0 L 688.0,817.0 L 687.0,816.0 L 686.0,816.0 L 685.0,815.0 L 684.0,815.0 L 683.0,814.0 L 675.0,814.0 L 674.0,815.0 L 673.0,815.0 L 672.0,816.0 L 671.0,816.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 642.0,814.0 L 641.0,815.0 L 641.0,837.0 L 645.0,837.0 L 645.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 605.0,814.0 L 605.0,837.0 L 621.0,837.0 L 621.0,835.0 L 620.0,834.0 L 610.0,834.0 L 609.0,833.0 L 609.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 569.0,814.0 L 569.0,837.0 L 572.0,837.0 L 573.0,838.0 L 575.0,838.0 L 576.0,837.0 L 583.0,837.0 L 584.0,836.0 L 584.0,835.0 L 583.0,834.0 L 574.0,834.0 L 573.0,833.0 L 573.0,824.0 L 572.0,823.0 L 572.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 529.0,814.0 L 529.0,837.0 L 533.0,837.0 L 534.0,838.0 L 537.0,838.0 L 538.0,837.0 L 546.0,837.0 L 546.0,834.0 L 534.0,834.0 L 533.0,833.0 L 533.0,829.0 L 534.0,828.0 L 544.0,828.0 L 545.0,827.0 L 545.0,825.0 L 544.0,824.0 L 534.0,824.0 L 533.0,823.0 L 533.0,819.0 L 534.0,818.0 L 546.0,818.0 L 546.0,815.0 L 545.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 490.0,814.0 L 489.0,815.0 L 489.0,817.0 L 490.0,818.0 L 495.0,818.0 L 496.0,819.0 L 496.0,837.0 L 500.0,837.0 L 500.0,819.0 L 501.0,818.0 L 507.0,818.0 L 507.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 447.0,814.0 L 447.0,837.0 L 451.0,837.0 L 451.0,827.0 L 452.0,826.0 L 452.0,825.0 L 453.0,824.0 L 455.0,826.0 L 455.0,827.0 L 457.0,829.0 L 457.0,830.0 L 460.0,833.0 L 460.0,834.0 L 463.0,837.0 L 468.0,837.0 L 468.0,814.0 L 464.0,814.0 L 463.0,815.0 L 463.0,827.0 L 462.0,828.0 L 461.0,827.0 L 461.0,826.0 L 458.0,823.0 L 458.0,822.0 L 455.0,819.0 L 455.0,818.0 L 451.0,814.0 Z M 451.0,823.0 L 452.0,822.0 L 453.0,823.0 L 453.0,824.0 L 452.0,825.0 L 451.0,824.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 420.0,814.0 L 420.0,837.0 L 424.0,837.0 L 424.0,815.0 L 423.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 355.0,814.0 L 354.0,815.0 L 354.0,837.0 L 362.0,837.0 L 363.0,838.0 L 367.0,838.0 L 368.0,837.0 L 369.0,837.0 L 369.0,834.0 L 359.0,834.0 L 358.0,833.0 L 358.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 316.0,814.0 L 315.0,815.0 L 315.0,837.0 L 332.0,837.0 L 332.0,836.0 L 331.0,835.0 L 331.0,834.0 L 320.0,834.0 L 319.0,833.0 L 319.0,828.0 L 320.0,827.0 L 321.0,828.0 L 322.0,827.0 L 323.0,828.0 L 330.0,828.0 L 330.0,824.0 L 320.0,824.0 L 319.0,823.0 L 319.0,819.0 L 320.0,818.0 L 331.0,818.0 L 331.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 273.0,814.0 L 272.0,815.0 L 272.0,832.0 L 273.0,833.0 L 273.0,834.0 L 275.0,836.0 L 276.0,836.0 L 277.0,837.0 L 279.0,837.0 L 280.0,838.0 L 285.0,838.0 L 286.0,837.0 L 287.0,837.0 L 291.0,833.0 L 291.0,830.0 L 292.0,829.0 L 292.0,815.0 L 291.0,814.0 L 289.0,814.0 L 288.0,815.0 L 288.0,830.0 L 287.0,831.0 L 287.0,832.0 L 286.0,833.0 L 285.0,833.0 L 284.0,834.0 L 279.0,834.0 L 277.0,832.0 L 277.0,831.0 L 276.0,830.0 L 276.0,815.0 L 275.0,814.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,814.0 L 235.0,814.0 L 234.0,815.0 L 234.0,837.0 L 238.0,837.0 L 238.0,829.0 L 239.0,828.0 L 248.0,828.0 L 249.0,827.0 L 249.0,825.0 L 248.0,824.0 L 241.0,824.0 L 240.0,825.0 L 239.0,825.0 L 238.0,824.0 L 238.0,819.0 L 239.0,818.0 L 250.0,818.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 807.0,813.0 L 806.0,814.0 L 803.0,814.0 L 801.0,816.0 L 800.0,816.0 L 798.0,818.0 L 798.0,819.0 L 797.0,820.0 L 797.0,821.0 L 796.0,822.0 L 796.0,829.0 L 797.0,830.0 L 797.0,832.0 L 801.0,836.0 L 802.0,836.0 L 803.0,837.0 L 804.0,837.0 L 805.0,838.0 L 811.0,838.0 L 812.0,837.0 L 813.0,837.0 L 814.0,836.0 L 815.0,836.0 L 818.0,833.0 L 818.0,831.0 L 817.0,831.0 L 816.0,830.0 L 814.0,830.0 L 814.0,831.0 L 813.0,832.0 L 812.0,832.0 L 811.0,833.0 L 809.0,833.0 L 808.0,834.0 L 807.0,833.0 L 805.0,833.0 L 804.0,832.0 L 803.0,832.0 L 802.0,831.0 L 802.0,830.0 L 801.0,829.0 L 801.0,822.0 L 804.0,819.0 L 805.0,819.0 L 806.0,818.0 L 810.0,818.0 L 811.0,819.0 L 812.0,819.0 L 814.0,821.0 L 816.0,821.0 L 817.0,820.0 L 818.0,820.0 L 818.0,818.0 L 815.0,815.0 L 814.0,815.0 L 813.0,814.0 L 809.0,814.0 L 808.0,813.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst">
<!-- Petrolyst's wordmark alone: petrolyst-web/design/brand/02_Logo/Vector_Master/
petrolyst_wordmark_fullcolor.svg with the letters white for the dark chrome (PD-26); the Y keeps its original gradient. -->
<defs>
<linearGradient id="wordYGradient" x1="945" y1="660" x2="1090" y2="780" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#009688"/>
<stop offset="100%" stop-color="#00B6E6"/>
</linearGradient>
</defs>
<g fill="#FFFFFF">
<path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,715.0 L 250.0,729.0 L 266.0,729.0 L 267.0,730.0 L 301.0,730.0 L 302.0,729.0 L 307.0,729.0 L 308.0,730.0 L 325.0,730.0 L 326.0,729.0 L 340.0,729.0 L 340.0,716.0 L 339.0,715.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1260.0,678.0 L 1260.0,692.0 L 1264.0,692.0 L 1265.0,693.0 L 1296.0,693.0 L 1297.0,694.0 L 1297.0,767.0 L 1313.0,767.0 L 1313.0,694.0 L 1314.0,693.0 L 1351.0,693.0 L 1351.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 867.0,678.0 L 867.0,767.0 L 951.0,767.0 L 951.0,753.0 L 950.0,752.0 L 883.0,752.0 L 882.0,751.0 L 882.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 551.0,678.0 L 551.0,767.0 L 566.0,767.0 L 566.0,743.0 L 567.0,742.0 L 567.0,739.0 L 566.0,738.0 L 567.0,737.0 L 604.0,737.0 L 614.0,747.0 L 614.0,748.0 L 622.0,756.0 L 622.0,757.0 L 630.0,765.0 L 630.0,766.0 L 631.0,767.0 L 651.0,767.0 L 651.0,766.0 L 649.0,764.0 L 649.0,763.0 L 642.0,756.0 L 642.0,755.0 L 628.0,741.0 L 628.0,740.0 L 626.0,738.0 L 628.0,736.0 L 630.0,736.0 L 631.0,735.0 L 633.0,735.0 L 634.0,734.0 L 636.0,734.0 L 638.0,732.0 L 639.0,732.0 L 645.0,726.0 L 645.0,725.0 L 648.0,720.0 L 648.0,718.0 L 649.0,717.0 L 649.0,713.0 L 650.0,712.0 L 650.0,703.0 L 649.0,702.0 L 649.0,698.0 L 648.0,697.0 L 648.0,695.0 L 647.0,694.0 L 646.0,691.0 L 644.0,689.0 L 644.0,688.0 L 640.0,684.0 L 639.0,684.0 L 637.0,682.0 L 636.0,682.0 L 633.0,680.0 L 631.0,680.0 L 630.0,679.0 L 626.0,679.0 L 625.0,678.0 Z M 567.0,694.0 L 568.0,693.0 L 625.0,693.0 L 626.0,694.0 L 629.0,695.0 L 632.0,698.0 L 632.0,699.0 L 633.0,700.0 L 633.0,713.0 L 632.0,714.0 L 632.0,716.0 L 630.0,718.0 L 630.0,719.0 L 629.0,720.0 L 628.0,720.0 L 626.0,722.0 L 622.0,722.0 L 621.0,723.0 L 568.0,723.0 L 566.0,721.0 L 566.0,703.0 L 567.0,702.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 398.0,678.0 L 398.0,693.0 L 434.0,693.0 L 435.0,694.0 L 435.0,767.0 L 451.0,767.0 L 451.0,694.0 L 452.0,693.0 L 489.0,693.0 L 489.0,679.0 L 488.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,678.0 L 250.0,692.0 L 251.0,692.0 L 252.0,693.0 L 339.0,693.0 L 339.0,691.0 L 340.0,690.0 L 340.0,679.0 L 339.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1134.0,683.0 L 1129.0,688.0 L 1129.0,689.0 L 1127.0,691.0 L 1127.0,693.0 L 1126.0,694.0 L 1126.0,696.0 L 1125.0,697.0 L 1125.0,712.0 L 1126.0,713.0 L 1126.0,715.0 L 1127.0,716.0 L 1128.0,719.0 L 1134.0,725.0 L 1135.0,725.0 L 1137.0,727.0 L 1139.0,727.0 L 1140.0,728.0 L 1143.0,728.0 L 1144.0,729.0 L 1185.0,729.0 L 1186.0,730.0 L 1191.0,730.0 L 1192.0,731.0 L 1193.0,731.0 L 1197.0,735.0 L 1197.0,739.0 L 1198.0,740.0 L 1198.0,742.0 L 1197.0,743.0 L 1197.0,746.0 L 1196.0,747.0 L 1196.0,748.0 L 1193.0,751.0 L 1192.0,751.0 L 1189.0,753.0 L 1184.0,753.0 L 1183.0,754.0 L 1151.0,754.0 L 1150.0,753.0 L 1143.0,753.0 L 1142.0,752.0 L 1139.0,752.0 L 1138.0,751.0 L 1135.0,751.0 L 1134.0,750.0 L 1131.0,750.0 L 1130.0,749.0 L 1128.0,749.0 L 1127.0,748.0 L 1125.0,748.0 L 1125.0,760.0 L 1124.0,761.0 L 1126.0,763.0 L 1128.0,763.0 L 1129.0,764.0 L 1131.0,764.0 L 1132.0,765.0 L 1134.0,765.0 L 1135.0,766.0 L 1139.0,766.0 L 1140.0,767.0 L 1146.0,767.0 L 1147.0,768.0 L 1186.0,768.0 L 1187.0,767.0 L 1191.0,767.0 L 1192.0,766.0 L 1195.0,766.0 L 1196.0,765.0 L 1198.0,765.0 L 1199.0,764.0 L 1201.0,764.0 L 1203.0,762.0 L 1204.0,762.0 L 1209.0,757.0 L 1209.0,756.0 L 1211.0,754.0 L 1211.0,753.0 L 1212.0,752.0 L 1212.0,750.0 L 1213.0,749.0 L 1213.0,734.0 L 1212.0,733.0 L 1212.0,730.0 L 1211.0,729.0 L 1211.0,728.0 L 1209.0,726.0 L 1209.0,725.0 L 1204.0,720.0 L 1203.0,720.0 L 1202.0,719.0 L 1201.0,719.0 L 1196.0,716.0 L 1192.0,716.0 L 1191.0,715.0 L 1148.0,715.0 L 1147.0,714.0 L 1145.0,714.0 L 1142.0,711.0 L 1142.0,710.0 L 1141.0,709.0 L 1141.0,699.0 L 1142.0,698.0 L 1142.0,697.0 L 1145.0,694.0 L 1146.0,694.0 L 1147.0,693.0 L 1150.0,693.0 L 1151.0,692.0 L 1190.0,692.0 L 1191.0,693.0 L 1196.0,693.0 L 1197.0,694.0 L 1201.0,694.0 L 1202.0,695.0 L 1204.0,695.0 L 1205.0,696.0 L 1209.0,696.0 L 1209.0,683.0 L 1206.0,681.0 L 1203.0,681.0 L 1202.0,680.0 L 1198.0,680.0 L 1197.0,679.0 L 1193.0,679.0 L 1192.0,678.0 L 1182.0,678.0 L 1181.0,677.0 L 1156.0,677.0 L 1155.0,678.0 L 1146.0,678.0 L 1145.0,679.0 L 1143.0,679.0 L 1142.0,680.0 L 1140.0,680.0 L 1139.0,681.0 L 1138.0,681.0 L 1137.0,682.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 714.0,691.0 L 714.0,692.0 L 711.0,695.0 L 711.0,696.0 L 709.0,698.0 L 709.0,699.0 L 708.0,700.0 L 708.0,702.0 L 707.0,703.0 L 707.0,706.0 L 706.0,707.0 L 706.0,711.0 L 705.0,712.0 L 705.0,734.0 L 706.0,735.0 L 706.0,739.0 L 707.0,740.0 L 707.0,743.0 L 708.0,744.0 L 708.0,745.0 L 709.0,746.0 L 709.0,747.0 L 710.0,748.0 L 711.0,751.0 L 713.0,753.0 L 713.0,754.0 L 718.0,759.0 L 719.0,759.0 L 721.0,761.0 L 722.0,761.0 L 723.0,762.0 L 724.0,762.0 L 729.0,765.0 L 731.0,765.0 L 732.0,766.0 L 735.0,766.0 L 736.0,767.0 L 741.0,767.0 L 742.0,768.0 L 775.0,768.0 L 776.0,767.0 L 781.0,767.0 L 782.0,766.0 L 785.0,766.0 L 786.0,765.0 L 788.0,765.0 L 789.0,764.0 L 790.0,764.0 L 791.0,763.0 L 792.0,763.0 L 793.0,762.0 L 796.0,761.0 L 798.0,759.0 L 799.0,759.0 L 805.0,753.0 L 805.0,752.0 L 807.0,750.0 L 807.0,749.0 L 809.0,746.0 L 809.0,744.0 L 811.0,741.0 L 811.0,737.0 L 812.0,736.0 L 812.0,711.0 L 811.0,710.0 L 811.0,706.0 L 810.0,705.0 L 810.0,703.0 L 809.0,702.0 L 809.0,700.0 L 808.0,699.0 L 808.0,698.0 L 806.0,696.0 L 806.0,695.0 L 803.0,692.0 L 803.0,691.0 L 799.0,687.0 L 798.0,687.0 L 793.0,683.0 L 792.0,683.0 L 791.0,682.0 L 789.0,682.0 L 786.0,680.0 L 783.0,680.0 L 782.0,679.0 L 779.0,679.0 L 778.0,678.0 L 770.0,678.0 L 769.0,677.0 L 748.0,677.0 L 747.0,678.0 L 739.0,678.0 L 738.0,679.0 L 734.0,679.0 L 733.0,680.0 L 731.0,680.0 L 730.0,681.0 L 728.0,681.0 L 727.0,682.0 L 726.0,682.0 L 725.0,683.0 L 722.0,684.0 L 720.0,686.0 L 719.0,686.0 Z M 725.0,702.0 L 730.0,697.0 L 731.0,697.0 L 733.0,695.0 L 735.0,695.0 L 738.0,693.0 L 742.0,693.0 L 743.0,692.0 L 772.0,692.0 L 773.0,693.0 L 777.0,693.0 L 778.0,694.0 L 781.0,694.0 L 782.0,695.0 L 785.0,696.0 L 787.0,698.0 L 788.0,698.0 L 791.0,701.0 L 791.0,702.0 L 793.0,704.0 L 793.0,705.0 L 794.0,706.0 L 794.0,708.0 L 795.0,709.0 L 795.0,712.0 L 796.0,713.0 L 796.0,733.0 L 795.0,734.0 L 795.0,738.0 L 794.0,739.0 L 793.0,742.0 L 791.0,744.0 L 791.0,745.0 L 788.0,748.0 L 787.0,748.0 L 785.0,750.0 L 784.0,750.0 L 781.0,752.0 L 778.0,752.0 L 777.0,753.0 L 770.0,753.0 L 769.0,754.0 L 747.0,754.0 L 746.0,753.0 L 740.0,753.0 L 739.0,752.0 L 737.0,752.0 L 736.0,751.0 L 734.0,751.0 L 733.0,750.0 L 732.0,750.0 L 730.0,748.0 L 729.0,748.0 L 725.0,744.0 L 725.0,743.0 L 722.0,738.0 L 722.0,734.0 L 721.0,733.0 L 721.0,713.0 L 722.0,712.0 L 722.0,709.0 L 723.0,708.0 L 723.0,706.0 L 724.0,705.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 95.0,678.0 L 95.0,766.0 L 96.0,767.0 L 111.0,767.0 L 111.0,737.0 L 112.0,736.0 L 171.0,736.0 L 172.0,735.0 L 175.0,735.0 L 176.0,734.0 L 178.0,734.0 L 179.0,733.0 L 180.0,733.0 L 182.0,731.0 L 183.0,731.0 L 186.0,728.0 L 187.0,728.0 L 187.0,727.0 L 190.0,724.0 L 190.0,723.0 L 193.0,718.0 L 193.0,715.0 L 194.0,714.0 L 194.0,702.0 L 193.0,701.0 L 193.0,696.0 L 192.0,695.0 L 192.0,693.0 L 191.0,692.0 L 190.0,689.0 L 188.0,687.0 L 188.0,686.0 L 185.0,683.0 L 184.0,683.0 L 182.0,681.0 L 181.0,681.0 L 180.0,680.0 L 178.0,680.0 L 177.0,679.0 L 174.0,679.0 L 173.0,678.0 L 168.0,678.0 L 167.0,677.0 L 156.0,677.0 L 155.0,678.0 L 135.0,678.0 L 134.0,677.0 L 132.0,677.0 L 131.0,678.0 L 108.0,678.0 L 107.0,677.0 L 105.0,677.0 L 104.0,678.0 Z M 111.0,693.0 L 112.0,692.0 L 169.0,692.0 L 170.0,693.0 L 172.0,693.0 L 177.0,698.0 L 177.0,699.0 L 178.0,700.0 L 178.0,712.0 L 177.0,713.0 L 177.0,715.0 L 176.0,716.0 L 176.0,717.0 L 174.0,719.0 L 173.0,719.0 L 170.0,721.0 L 167.0,721.0 L 166.0,722.0 L 154.0,722.0 L 153.0,721.0 L 152.0,722.0 L 115.0,722.0 L 114.0,721.0 L 113.0,722.0 L 111.0,720.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<rect x="250" y="678" width="16" height="90" fill="#FFFFFF"/>
</g>
<path d="M 1084.0,678.0 L 1063.0,678.0 L 1056.0,685.0 L 1056.0,686.0 L 1049.0,693.0 L 1049.0,694.0 L 1042.0,701.0 L 1042.0,702.0 L 1036.0,708.0 L 1036.0,709.0 L 1035.0,710.0 L 1035.0,711.0 L 1033.0,713.0 L 1033.0,714.0 L 1032.0,715.0 L 1032.0,716.0 L 1031.0,717.0 L 1031.0,718.0 L 1030.0,719.0 L 1030.0,720.0 L 1028.0,723.0 L 1028.0,725.0 L 1026.0,728.0 L 1026.0,730.0 L 1025.0,731.0 L 1025.0,733.0 L 1024.0,734.0 L 1024.0,738.0 L 1023.0,739.0 L 1023.0,743.0 L 1022.0,744.0 L 1022.0,752.0 L 1021.0,753.0 L 1021.0,766.0 L 1022.0,767.0 L 1038.0,767.0 L 1038.0,737.0 L 1039.0,736.0 L 1039.0,734.0 L 1040.0,733.0 L 1040.0,732.0 L 1041.0,731.0 L 1041.0,730.0 L 1043.0,728.0 L 1043.0,727.0 L 1046.0,724.0 L 1046.0,723.0 L 1052.0,717.0 L 1052.0,716.0 L 1060.0,708.0 L 1060.0,707.0 L 1065.0,702.0 L 1065.0,701.0 L 1072.0,694.0 L 1072.0,693.0 L 1077.0,688.0 L 1077.0,687.0 L 1082.0,682.0 L 1082.0,681.0 L 1084.0,679.0 L 1085.0,679.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
<path d="M 975.0,678.0 L 975.0,679.0 L 979.0,683.0 L 979.0,684.0 L 983.0,688.0 L 983.0,689.0 L 990.0,696.0 L 990.0,697.0 L 996.0,703.0 L 996.0,704.0 L 1002.0,710.0 L 1002.0,711.0 L 1010.0,719.0 L 1010.0,720.0 L 1014.0,724.0 L 1014.0,725.0 L 1017.0,728.0 L 1017.0,729.0 L 1018.0,730.0 L 1018.0,731.0 L 1019.0,732.0 L 1020.0,732.0 L 1020.0,729.0 L 1021.0,728.0 L 1021.0,726.0 L 1022.0,725.0 L 1022.0,722.0 L 1023.0,721.0 L 1023.0,715.0 L 1024.0,714.0 L 1023.0,713.0 L 1023.0,709.0 L 1022.0,708.0 L 1022.0,707.0 L 1021.0,706.0 L 1021.0,705.0 L 1016.0,700.0 L 1016.0,699.0 L 1010.0,693.0 L 1010.0,692.0 L 1003.0,685.0 L 1003.0,684.0 L 997.0,678.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,26 @@
# Petrolyst's sign-in and sign-out pages, on Keycloak's unstyled base theme: only our own stylesheet, so a Keycloak upgrade cannot
# restyle them. The brand's home is Petrolyst/design/; css/brand/ holds copies (../check-brand.sh).
parent=base
locales=en
meta=viewport==width=device-width,initial-scale=1
styles=css/brand/petrolyst_tokens.css css/brand/product_identity_layer.css css/petrolyst.css
kcLoginClass=pl-page
kcHeaderClass=pl-header
kcFormCardClass=pl-card
kcFormGroupClass=pl-group
kcLabelClass=pl-label
kcInputClass=pl-input
kcInputGroup=pl-input-group
kcInputErrorMessageClass=pl-field-error
kcAlertClass=pl-alert
kcAlertTitleClass=pl-alert-text
kcButtonClass=pl-btn
kcButtonPrimaryClass=pl-btn-primary
kcButtonDefaultClass=pl-btn-secondary
kcButtonBlockClass=pl-btn-block
kcFormPasswordVisibilityButtonClass=pl-eye
kcFormPasswordVisibilityIconShow=pl-icon pl-icon-show
kcFormPasswordVisibilityIconHide=pl-icon pl-icon-hide
kcResetFlowIcon=pl-icon pl-icon-restart
kcFormSettingClass=pl-settings
+32 -7
View File
@@ -15,8 +15,9 @@ and where it runs lives in `stacks/`, `traefik/`, `deploy/` and `host/`.
|---|---|---| |---|---|---|
| `superset_config.py` | Keycloak sign-in, role mapping, proxy and cookie settings, Redis caches, theme and chart palettes | `/app/pythonpath/` | | `superset_config.py` | Keycloak sign-in, role mapping, proxy and cookie settings, Redis caches, theme and chart palettes | `/app/pythonpath/` |
| `petrolyst_security.py` | `PetrolystSecurityManager`: user info and roles from the Keycloak ID token, `/login/` straight to Keycloak, `/logout/` through Keycloak | `/app/pythonpath/` | | `petrolyst_security.py` | `PetrolystSecurityManager`: user info and roles from the Keycloak ID token, `/login/` straight to Keycloak, `/logout/` through Keycloak | `/app/pythonpath/` |
| `assets/branding/` | logos (full colour and white), favicons, app icons | `/app/superset/static/assets/branding/`, served at `/static/assets/branding/…` | | `roles/` | `petrolyst_guest.py`: the role "Petrolyst Guest" for the Petrolyst app's embedded dashboards, with exactly its permissions; run by hand (see "Embedding") | `/app/petrolyst-roles/` |
| `patches/` | runtime patches, `fix_*.py` with `--verify` (see its README); empty at start | `/app/patches/petrolyst/`, applied during the build | | `assets/branding/` | logos (full colour and white): the wordmark alone for the navbar, and the stacked logo; favicons, app icons; ATOMA's logo, which the Petrolyst app shows in its map's corner (the server reads this file itself: `PETROLYST_OPERATOR_LOGO_FILE`) | `/app/superset/static/assets/branding/`, served at `/static/assets/branding/…` |
| `patches/` | runtime patches, `fix_*.py` with `--verify` (see its README); none at present | `/app/patches/petrolyst/`, applied during the build |
| `dashboards/` | exported dashboards and import scripts (see its README) | not in the image | | `dashboards/` | exported dashboards and import scripts (see its README) | not in the image |
How the image consumes this folder: `stacks/fuel-monitoring/compose.yaml` passes it to the build How the image consumes this folder: `stacks/fuel-monitoring/compose.yaml` passes it to the build
@@ -34,13 +35,13 @@ value.
|---|---| |---|---|
| `superset_secret_key` | Flask `SECRET_KEY` | | `superset_secret_key` | Flask `SECRET_KEY` |
| `superset_db_password` | password of user `superset` on the metadata database | | `superset_db_password` | password of user `superset` on the metadata database |
| `keycloak_petrolyst_client_secret` | client secret of Keycloak client `petrolyst` | | `keycloak_petrolyst_client_secret` | client secret of Keycloak client `petrolyst`, realm `petrolyst`; on the server in `shared/keycloak_petrolyst_realm_client_secret` |
On the server they come from `/opt/petrolyst/secrets/` (see the deployment README). On the server they come from `/opt/petrolyst/secrets/` (see the deployment README).
## Sign-in and roles ## Sign-in and roles
- Provider: Keycloak realm `thedigitalbridge` at `iam.thedigitalbridge.io`, client `petrolyst`, - Provider: Keycloak realm `petrolyst` at `iam.thedigitalbridge.io`, client `petrolyst`,
authorization code flow with PKCE S256. The realm URL can be overridden with authorization code flow with PKCE S256. The realm URL can be overridden with
`KEYCLOAK_REALM_URL`. `KEYCLOAK_REALM_URL`.
- Roles come from the client roles of `petrolyst`, which a protocol mapper puts into the claim - Roles come from the client roles of `petrolyst`, which a protocol mapper puts into the claim
@@ -51,20 +52,44 @@ On the server they come from `/opt/petrolyst/secrets/` (see the deployment READM
| `superset-admin` | Admin | | `superset-admin` | Admin |
| `superset-editor` | Alpha | | `superset-editor` | Alpha |
| `superset-viewer` | Gamma | | `superset-viewer` | Gamma |
| `atoma-reader` | none (Public): ATOMA's users use the Petrolyst app, not Superset (PD-13) |
| none | Public (no permissions) | | none | Public (no permissions) |
- `DASHBOARD_RBAC` is on: a viewer sees a dashboard once an admin adds the Gamma role (or a - `DASHBOARD_RBAC` is on: a viewer sees a dashboard once an admin adds the Gamma role (or a
custom one) to it. custom one) to it.
- `FILTERBAR_CLOSED_BY_DEFAULT` is on: a dashboard's filter column opens collapsed, a narrow
strip with an expand button; `?expand_filters=true` opens it from a link.
## Embedding (local trial only)
The Petrolyst app (PD-12, petrolyst-platform `app/` and `server/`) shows dashboards inside
its Insights lens with Superset guest passes. This is switched on **only in the local scratch
stack** (`.local/fmdb-scratch`); `superset_config.py` here leaves it off, and loads without the
guest secret.
- The scratch stack's `superset_config_local.py` turns on `EMBEDDED_SUPERSET`, names the guest
role `GUEST_ROLE_NAME = "Petrolyst Guest"`, reads the signing secret with
`_read_secret("superset_guest_token_secret")` and expects the audience `petrolyst-app`. The
Petrolyst server signs the guest passes itself with the same secret.
- `roles/petrolyst_guest.py` gives "Petrolyst Guest" exactly what a dashboard needs to be read. Which
dashboard a guest sees comes from the pass, not the role. It is not in `init.sh`; run it once,
and again whenever its permissions change:
docker exec <superset> /app/.venv/bin/python /app/petrolyst-roles/petrolyst_guest.py
- The app's dashboards and their embedding ids: `dashboards/README.md`.
Production embedding is an open point of PD-12 (`.context/design-decisions.md`).
## Look and feel ## Look and feel
Source: `petrolyst-ui/design/brand/` (tokens `07_UI_Tokens/petrolyst_tokens.css`, logos Source: `petrolyst-web/design/brand/` (tokens `07_UI_Tokens/petrolyst_tokens.css`, logos
`public/brand/`, favicons `03_Favicon_App_Icons/`), copied on 2026-10-23 from petrolyst-ui commit `public/brand/`, favicons `03_Favicon_App_Icons/`), copied on 2026-10-23 from petrolyst-web (then petrolyst-ui) commit
`2b2e694`. Copy again when the brand changes. `2b2e694`. Copy again when the brand changes.
- Light theme: action teal `#00695C` for primary and links, navy `#0B1D33` text, `#F8FAFC` - Light theme: action teal `#00695C` for primary and links, navy `#0B1D33` text, `#F8FAFC`
layout, full-colour stacked logo. layout, full-colour stacked logo.
- Dark theme: `#35C5B6` primary, `#071425` / `#12263D` surfaces, white logo. - Dark theme: `#35C5B6` primary, `#1F2A33` / `#2A3945` surfaces (PD-26), white logo with the Y in its green.
- Font: Inter, Superset's default. Satoshi is licence-restricted and is not used. - Font: Inter, Superset's default. Satoshi is licence-restricted and is not used.
- Chart palettes: "Petrolyst" categorical (the default, brand teal first) and "Petrolyst Teal" / - Chart palettes: "Petrolyst" categorical (the default, brand teal first) and "Petrolyst Teal" /
"Petrolyst Navy" sequential. The categorical palette was validated with the dataviz skill's "Petrolyst Navy" sequential. The categorical palette was validated with the dataviz skill's
@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="300 225 1900 520" role="img" aria-label="ATOMA">
<!-- ATOMA's logo as published on atoma.com.af (mtn-logo.svg, sha256 7a718cdc163c4c59...),
in its own colours; only the viewBox is cropped to the logo. Shown in the fleet map's
corner (dashboards/atoma/build_bundle.py, FLEET_MAP_SRC). -->
<path fill="#1229B3" d="M477.499,608.807h82.857l-82.857-248.571h-82.857l-82.857,248.571h82.857l5.524-16.571h71.809
L477.499,608.807z M422.261,525.95l13.81-41.428l13.809,41.428H422.261z"/>
<path fill="#1229B3" d="M1526.188,608.807h82.856l-82.856-248.571h-82.857l-82.856,248.571h82.856l5.524-16.571h71.809
L1526.188,608.807z M1470.95,525.95l13.81-41.428l13.81,41.428H1470.95z"/>
<polygon fill="#1229B3" points="792.355,360.236 709.498,360.236 626.641,360.236 543.784,360.236 543.784,443.093 626.641,443.093
626.641,608.808 709.498,608.808 709.498,443.093 792.355,443.093 "/>
<g>
<defs>
<rect id="SVGID_1_" width="2500" height="1000"/>
</defs>
<clipPath id="SVGID_2_">
<use xlink:href="#SVGID_1_" overflow="visible"/>
</clipPath>
<path clip-path="url(#SVGID_2_)" fill="#1229B3" d="M933.212,360.236c-68.641,0-124.285,55.645-124.285,124.286
c0,68.64,55.644,124.285,124.285,124.285s124.285-55.645,124.285-124.285C1057.497,415.881,1001.853,360.236,933.212,360.236
M933.212,525.95c-22.88,0-41.428-18.548-41.428-41.428c0-22.881,18.548-41.429,41.428-41.429s41.428,18.548,41.428,41.429
C974.64,507.402,956.092,525.95,933.212,525.95"/>
<polygon clip-path="url(#SVGID_2_)" fill="#1229B3" points="1248.068,360.236 1206.64,401.665 1165.211,360.236 1082.354,360.236
1082.354,608.808 1165.211,608.808 1165.211,484.521 1206.64,525.95 1248.068,484.521 1248.068,608.808 1330.925,608.808
1330.925,360.236 "/>
<path clip-path="url(#SVGID_2_)" fill="#00C8FF" d="M1929.197,240.505l-106.099,106.098c-5.857,5.858-5.857,15.356,0,21.214
l116.705,116.705l-116.705,116.705c-5.857,5.858-5.857,15.356,0,21.214l106.099,106.098c5.857,5.858,15.355,5.858,21.214,0
l116.705-116.704l116.704-116.705c5.858-5.858,5.858-15.356,0-21.214L2067.116,357.21l-116.705-116.705
C1944.553,234.647,1935.055,234.647,1929.197,240.505"/>
<path clip-path="url(#SVGID_2_)" fill="#2449FB" d="M2056.782,494.855l-116.978,116.978l-244.291-244.29
c-5.707-5.707-5.707-14.96,0-20.667l106.645-106.645c5.707-5.707,14.96-5.707,20.667,0l233.957,233.957
C2062.489,479.895,2062.489,489.148,2056.782,494.855"/>
<path clip-path="url(#SVGID_2_)" fill="#1229B3" d="M1802.158,728.812l-106.645-106.645c-5.707-5.707-5.707-14.96,0-20.667
l116.978-116.978l127.313,127.312l-116.978,116.978C1817.119,734.519,1807.865,734.519,1802.158,728.812"/>
<path clip-path="url(#SVGID_2_)" fill="#00AAFF" d="M1823.1,346.603c-5.858,5.858-5.858,15.356,0,21.214l116.705,116.705
L1823.1,601.227c-5.858,5.858-5.858,15.356,0,21.214l53.049,53.049l127.312-127.312l53.049-53.049
c5.858-5.858,5.858-15.356,0-21.214l-53.049-53.049l-127.312-127.312L1823.1,346.603z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst">
<!-- Petrolyst's wordmark alone: petrolyst-web/design/brand/02_Logo/Vector_Master/
petrolyst_logo_standard_fullcolor_no_tagline.svg without the Y mark above it, paths and
colours unchanged, the viewBox cropped to the letters. The navbar logo (superset_config.py). -->
<defs>
<linearGradient id="wordYGradient" x1="945" y1="660" x2="1090" y2="780" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#009688"/>
<stop offset="100%" stop-color="#00B6E6"/>
</linearGradient>
</defs>
<g fill="#0B1D33">
<path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<path d="M 250.0,715.0 L 250.0,729.0 L 266.0,729.0 L 267.0,730.0 L 301.0,730.0 L 302.0,729.0 L 307.0,729.0 L 308.0,730.0 L 325.0,730.0 L 326.0,729.0 L 340.0,729.0 L 340.0,716.0 L 339.0,715.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<path d="M 1260.0,678.0 L 1260.0,692.0 L 1264.0,692.0 L 1265.0,693.0 L 1296.0,693.0 L 1297.0,694.0 L 1297.0,767.0 L 1313.0,767.0 L 1313.0,694.0 L 1314.0,693.0 L 1351.0,693.0 L 1351.0,678.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<path d="M 867.0,678.0 L 867.0,767.0 L 951.0,767.0 L 951.0,753.0 L 950.0,752.0 L 883.0,752.0 L 882.0,751.0 L 882.0,678.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<path d="M 551.0,678.0 L 551.0,767.0 L 566.0,767.0 L 566.0,743.0 L 567.0,742.0 L 567.0,739.0 L 566.0,738.0 L 567.0,737.0 L 604.0,737.0 L 614.0,747.0 L 614.0,748.0 L 622.0,756.0 L 622.0,757.0 L 630.0,765.0 L 630.0,766.0 L 631.0,767.0 L 651.0,767.0 L 651.0,766.0 L 649.0,764.0 L 649.0,763.0 L 642.0,756.0 L 642.0,755.0 L 628.0,741.0 L 628.0,740.0 L 626.0,738.0 L 628.0,736.0 L 630.0,736.0 L 631.0,735.0 L 633.0,735.0 L 634.0,734.0 L 636.0,734.0 L 638.0,732.0 L 639.0,732.0 L 645.0,726.0 L 645.0,725.0 L 648.0,720.0 L 648.0,718.0 L 649.0,717.0 L 649.0,713.0 L 650.0,712.0 L 650.0,703.0 L 649.0,702.0 L 649.0,698.0 L 648.0,697.0 L 648.0,695.0 L 647.0,694.0 L 646.0,691.0 L 644.0,689.0 L 644.0,688.0 L 640.0,684.0 L 639.0,684.0 L 637.0,682.0 L 636.0,682.0 L 633.0,680.0 L 631.0,680.0 L 630.0,679.0 L 626.0,679.0 L 625.0,678.0 Z M 567.0,694.0 L 568.0,693.0 L 625.0,693.0 L 626.0,694.0 L 629.0,695.0 L 632.0,698.0 L 632.0,699.0 L 633.0,700.0 L 633.0,713.0 L 632.0,714.0 L 632.0,716.0 L 630.0,718.0 L 630.0,719.0 L 629.0,720.0 L 628.0,720.0 L 626.0,722.0 L 622.0,722.0 L 621.0,723.0 L 568.0,723.0 L 566.0,721.0 L 566.0,703.0 L 567.0,702.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<path d="M 398.0,678.0 L 398.0,693.0 L 434.0,693.0 L 435.0,694.0 L 435.0,767.0 L 451.0,767.0 L 451.0,694.0 L 452.0,693.0 L 489.0,693.0 L 489.0,679.0 L 488.0,678.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<path d="M 250.0,678.0 L 250.0,692.0 L 251.0,692.0 L 252.0,693.0 L 339.0,693.0 L 339.0,691.0 L 340.0,690.0 L 340.0,679.0 L 339.0,678.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<path d="M 1134.0,683.0 L 1129.0,688.0 L 1129.0,689.0 L 1127.0,691.0 L 1127.0,693.0 L 1126.0,694.0 L 1126.0,696.0 L 1125.0,697.0 L 1125.0,712.0 L 1126.0,713.0 L 1126.0,715.0 L 1127.0,716.0 L 1128.0,719.0 L 1134.0,725.0 L 1135.0,725.0 L 1137.0,727.0 L 1139.0,727.0 L 1140.0,728.0 L 1143.0,728.0 L 1144.0,729.0 L 1185.0,729.0 L 1186.0,730.0 L 1191.0,730.0 L 1192.0,731.0 L 1193.0,731.0 L 1197.0,735.0 L 1197.0,739.0 L 1198.0,740.0 L 1198.0,742.0 L 1197.0,743.0 L 1197.0,746.0 L 1196.0,747.0 L 1196.0,748.0 L 1193.0,751.0 L 1192.0,751.0 L 1189.0,753.0 L 1184.0,753.0 L 1183.0,754.0 L 1151.0,754.0 L 1150.0,753.0 L 1143.0,753.0 L 1142.0,752.0 L 1139.0,752.0 L 1138.0,751.0 L 1135.0,751.0 L 1134.0,750.0 L 1131.0,750.0 L 1130.0,749.0 L 1128.0,749.0 L 1127.0,748.0 L 1125.0,748.0 L 1125.0,760.0 L 1124.0,761.0 L 1126.0,763.0 L 1128.0,763.0 L 1129.0,764.0 L 1131.0,764.0 L 1132.0,765.0 L 1134.0,765.0 L 1135.0,766.0 L 1139.0,766.0 L 1140.0,767.0 L 1146.0,767.0 L 1147.0,768.0 L 1186.0,768.0 L 1187.0,767.0 L 1191.0,767.0 L 1192.0,766.0 L 1195.0,766.0 L 1196.0,765.0 L 1198.0,765.0 L 1199.0,764.0 L 1201.0,764.0 L 1203.0,762.0 L 1204.0,762.0 L 1209.0,757.0 L 1209.0,756.0 L 1211.0,754.0 L 1211.0,753.0 L 1212.0,752.0 L 1212.0,750.0 L 1213.0,749.0 L 1213.0,734.0 L 1212.0,733.0 L 1212.0,730.0 L 1211.0,729.0 L 1211.0,728.0 L 1209.0,726.0 L 1209.0,725.0 L 1204.0,720.0 L 1203.0,720.0 L 1202.0,719.0 L 1201.0,719.0 L 1196.0,716.0 L 1192.0,716.0 L 1191.0,715.0 L 1148.0,715.0 L 1147.0,714.0 L 1145.0,714.0 L 1142.0,711.0 L 1142.0,710.0 L 1141.0,709.0 L 1141.0,699.0 L 1142.0,698.0 L 1142.0,697.0 L 1145.0,694.0 L 1146.0,694.0 L 1147.0,693.0 L 1150.0,693.0 L 1151.0,692.0 L 1190.0,692.0 L 1191.0,693.0 L 1196.0,693.0 L 1197.0,694.0 L 1201.0,694.0 L 1202.0,695.0 L 1204.0,695.0 L 1205.0,696.0 L 1209.0,696.0 L 1209.0,683.0 L 1206.0,681.0 L 1203.0,681.0 L 1202.0,680.0 L 1198.0,680.0 L 1197.0,679.0 L 1193.0,679.0 L 1192.0,678.0 L 1182.0,678.0 L 1181.0,677.0 L 1156.0,677.0 L 1155.0,678.0 L 1146.0,678.0 L 1145.0,679.0 L 1143.0,679.0 L 1142.0,680.0 L 1140.0,680.0 L 1139.0,681.0 L 1138.0,681.0 L 1137.0,682.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<path d="M 714.0,691.0 L 714.0,692.0 L 711.0,695.0 L 711.0,696.0 L 709.0,698.0 L 709.0,699.0 L 708.0,700.0 L 708.0,702.0 L 707.0,703.0 L 707.0,706.0 L 706.0,707.0 L 706.0,711.0 L 705.0,712.0 L 705.0,734.0 L 706.0,735.0 L 706.0,739.0 L 707.0,740.0 L 707.0,743.0 L 708.0,744.0 L 708.0,745.0 L 709.0,746.0 L 709.0,747.0 L 710.0,748.0 L 711.0,751.0 L 713.0,753.0 L 713.0,754.0 L 718.0,759.0 L 719.0,759.0 L 721.0,761.0 L 722.0,761.0 L 723.0,762.0 L 724.0,762.0 L 729.0,765.0 L 731.0,765.0 L 732.0,766.0 L 735.0,766.0 L 736.0,767.0 L 741.0,767.0 L 742.0,768.0 L 775.0,768.0 L 776.0,767.0 L 781.0,767.0 L 782.0,766.0 L 785.0,766.0 L 786.0,765.0 L 788.0,765.0 L 789.0,764.0 L 790.0,764.0 L 791.0,763.0 L 792.0,763.0 L 793.0,762.0 L 796.0,761.0 L 798.0,759.0 L 799.0,759.0 L 805.0,753.0 L 805.0,752.0 L 807.0,750.0 L 807.0,749.0 L 809.0,746.0 L 809.0,744.0 L 811.0,741.0 L 811.0,737.0 L 812.0,736.0 L 812.0,711.0 L 811.0,710.0 L 811.0,706.0 L 810.0,705.0 L 810.0,703.0 L 809.0,702.0 L 809.0,700.0 L 808.0,699.0 L 808.0,698.0 L 806.0,696.0 L 806.0,695.0 L 803.0,692.0 L 803.0,691.0 L 799.0,687.0 L 798.0,687.0 L 793.0,683.0 L 792.0,683.0 L 791.0,682.0 L 789.0,682.0 L 786.0,680.0 L 783.0,680.0 L 782.0,679.0 L 779.0,679.0 L 778.0,678.0 L 770.0,678.0 L 769.0,677.0 L 748.0,677.0 L 747.0,678.0 L 739.0,678.0 L 738.0,679.0 L 734.0,679.0 L 733.0,680.0 L 731.0,680.0 L 730.0,681.0 L 728.0,681.0 L 727.0,682.0 L 726.0,682.0 L 725.0,683.0 L 722.0,684.0 L 720.0,686.0 L 719.0,686.0 Z M 725.0,702.0 L 730.0,697.0 L 731.0,697.0 L 733.0,695.0 L 735.0,695.0 L 738.0,693.0 L 742.0,693.0 L 743.0,692.0 L 772.0,692.0 L 773.0,693.0 L 777.0,693.0 L 778.0,694.0 L 781.0,694.0 L 782.0,695.0 L 785.0,696.0 L 787.0,698.0 L 788.0,698.0 L 791.0,701.0 L 791.0,702.0 L 793.0,704.0 L 793.0,705.0 L 794.0,706.0 L 794.0,708.0 L 795.0,709.0 L 795.0,712.0 L 796.0,713.0 L 796.0,733.0 L 795.0,734.0 L 795.0,738.0 L 794.0,739.0 L 793.0,742.0 L 791.0,744.0 L 791.0,745.0 L 788.0,748.0 L 787.0,748.0 L 785.0,750.0 L 784.0,750.0 L 781.0,752.0 L 778.0,752.0 L 777.0,753.0 L 770.0,753.0 L 769.0,754.0 L 747.0,754.0 L 746.0,753.0 L 740.0,753.0 L 739.0,752.0 L 737.0,752.0 L 736.0,751.0 L 734.0,751.0 L 733.0,750.0 L 732.0,750.0 L 730.0,748.0 L 729.0,748.0 L 725.0,744.0 L 725.0,743.0 L 722.0,738.0 L 722.0,734.0 L 721.0,733.0 L 721.0,713.0 L 722.0,712.0 L 722.0,709.0 L 723.0,708.0 L 723.0,706.0 L 724.0,705.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<path d="M 95.0,678.0 L 95.0,766.0 L 96.0,767.0 L 111.0,767.0 L 111.0,737.0 L 112.0,736.0 L 171.0,736.0 L 172.0,735.0 L 175.0,735.0 L 176.0,734.0 L 178.0,734.0 L 179.0,733.0 L 180.0,733.0 L 182.0,731.0 L 183.0,731.0 L 186.0,728.0 L 187.0,728.0 L 187.0,727.0 L 190.0,724.0 L 190.0,723.0 L 193.0,718.0 L 193.0,715.0 L 194.0,714.0 L 194.0,702.0 L 193.0,701.0 L 193.0,696.0 L 192.0,695.0 L 192.0,693.0 L 191.0,692.0 L 190.0,689.0 L 188.0,687.0 L 188.0,686.0 L 185.0,683.0 L 184.0,683.0 L 182.0,681.0 L 181.0,681.0 L 180.0,680.0 L 178.0,680.0 L 177.0,679.0 L 174.0,679.0 L 173.0,678.0 L 168.0,678.0 L 167.0,677.0 L 156.0,677.0 L 155.0,678.0 L 135.0,678.0 L 134.0,677.0 L 132.0,677.0 L 131.0,678.0 L 108.0,678.0 L 107.0,677.0 L 105.0,677.0 L 104.0,678.0 Z M 111.0,693.0 L 112.0,692.0 L 169.0,692.0 L 170.0,693.0 L 172.0,693.0 L 177.0,698.0 L 177.0,699.0 L 178.0,700.0 L 178.0,712.0 L 177.0,713.0 L 177.0,715.0 L 176.0,716.0 L 176.0,717.0 L 174.0,719.0 L 173.0,719.0 L 170.0,721.0 L 167.0,721.0 L 166.0,722.0 L 154.0,722.0 L 153.0,721.0 L 152.0,722.0 L 115.0,722.0 L 114.0,721.0 L 113.0,722.0 L 111.0,720.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
<rect x="250" y="678" width="16" height="90" fill="#0B1D33"/>
</g>
<path d="M 1084.0,678.0 L 1063.0,678.0 L 1056.0,685.0 L 1056.0,686.0 L 1049.0,693.0 L 1049.0,694.0 L 1042.0,701.0 L 1042.0,702.0 L 1036.0,708.0 L 1036.0,709.0 L 1035.0,710.0 L 1035.0,711.0 L 1033.0,713.0 L 1033.0,714.0 L 1032.0,715.0 L 1032.0,716.0 L 1031.0,717.0 L 1031.0,718.0 L 1030.0,719.0 L 1030.0,720.0 L 1028.0,723.0 L 1028.0,725.0 L 1026.0,728.0 L 1026.0,730.0 L 1025.0,731.0 L 1025.0,733.0 L 1024.0,734.0 L 1024.0,738.0 L 1023.0,739.0 L 1023.0,743.0 L 1022.0,744.0 L 1022.0,752.0 L 1021.0,753.0 L 1021.0,766.0 L 1022.0,767.0 L 1038.0,767.0 L 1038.0,737.0 L 1039.0,736.0 L 1039.0,734.0 L 1040.0,733.0 L 1040.0,732.0 L 1041.0,731.0 L 1041.0,730.0 L 1043.0,728.0 L 1043.0,727.0 L 1046.0,724.0 L 1046.0,723.0 L 1052.0,717.0 L 1052.0,716.0 L 1060.0,708.0 L 1060.0,707.0 L 1065.0,702.0 L 1065.0,701.0 L 1072.0,694.0 L 1072.0,693.0 L 1077.0,688.0 L 1077.0,687.0 L 1082.0,682.0 L 1082.0,681.0 L 1084.0,679.0 L 1085.0,679.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
<path d="M 975.0,678.0 L 975.0,679.0 L 979.0,683.0 L 979.0,684.0 L 983.0,688.0 L 983.0,689.0 L 990.0,696.0 L 990.0,697.0 L 996.0,703.0 L 996.0,704.0 L 1002.0,710.0 L 1002.0,711.0 L 1010.0,719.0 L 1010.0,720.0 L 1014.0,724.0 L 1014.0,725.0 L 1017.0,728.0 L 1017.0,729.0 L 1018.0,730.0 L 1018.0,731.0 L 1019.0,732.0 L 1020.0,732.0 L 1020.0,729.0 L 1021.0,728.0 L 1021.0,726.0 L 1022.0,725.0 L 1022.0,722.0 L 1023.0,721.0 L 1023.0,715.0 L 1024.0,714.0 L 1023.0,713.0 L 1023.0,709.0 L 1022.0,708.0 L 1022.0,707.0 L 1021.0,706.0 L 1021.0,705.0 L 1016.0,700.0 L 1016.0,699.0 L 1010.0,693.0 L 1010.0,692.0 L 1003.0,685.0 L 1003.0,684.0 L 997.0,678.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst">
<!-- Petrolyst's wordmark alone: petrolyst-web/design/brand/02_Logo/Vector_Master/
petrolyst_logo_standard_white_no_tagline.svg without the Y mark above it, paths and
colours unchanged, the viewBox cropped to the letters. The navbar logo (superset_config.py). -->
<g fill="#FFFFFF"><path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,715.0 L 250.0,729.0 L 266.0,729.0 L 267.0,730.0 L 301.0,730.0 L 302.0,729.0 L 307.0,729.0 L 308.0,730.0 L 325.0,730.0 L 326.0,729.0 L 340.0,729.0 L 340.0,716.0 L 339.0,715.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1260.0,678.0 L 1260.0,692.0 L 1264.0,692.0 L 1265.0,693.0 L 1296.0,693.0 L 1297.0,694.0 L 1297.0,767.0 L 1313.0,767.0 L 1313.0,694.0 L 1314.0,693.0 L 1351.0,693.0 L 1351.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 867.0,678.0 L 867.0,767.0 L 951.0,767.0 L 951.0,753.0 L 950.0,752.0 L 883.0,752.0 L 882.0,751.0 L 882.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 551.0,678.0 L 551.0,767.0 L 566.0,767.0 L 566.0,743.0 L 567.0,742.0 L 567.0,739.0 L 566.0,738.0 L 567.0,737.0 L 604.0,737.0 L 614.0,747.0 L 614.0,748.0 L 622.0,756.0 L 622.0,757.0 L 630.0,765.0 L 630.0,766.0 L 631.0,767.0 L 651.0,767.0 L 651.0,766.0 L 649.0,764.0 L 649.0,763.0 L 642.0,756.0 L 642.0,755.0 L 628.0,741.0 L 628.0,740.0 L 626.0,738.0 L 628.0,736.0 L 630.0,736.0 L 631.0,735.0 L 633.0,735.0 L 634.0,734.0 L 636.0,734.0 L 638.0,732.0 L 639.0,732.0 L 645.0,726.0 L 645.0,725.0 L 648.0,720.0 L 648.0,718.0 L 649.0,717.0 L 649.0,713.0 L 650.0,712.0 L 650.0,703.0 L 649.0,702.0 L 649.0,698.0 L 648.0,697.0 L 648.0,695.0 L 647.0,694.0 L 646.0,691.0 L 644.0,689.0 L 644.0,688.0 L 640.0,684.0 L 639.0,684.0 L 637.0,682.0 L 636.0,682.0 L 633.0,680.0 L 631.0,680.0 L 630.0,679.0 L 626.0,679.0 L 625.0,678.0 Z M 567.0,694.0 L 568.0,693.0 L 625.0,693.0 L 626.0,694.0 L 629.0,695.0 L 632.0,698.0 L 632.0,699.0 L 633.0,700.0 L 633.0,713.0 L 632.0,714.0 L 632.0,716.0 L 630.0,718.0 L 630.0,719.0 L 629.0,720.0 L 628.0,720.0 L 626.0,722.0 L 622.0,722.0 L 621.0,723.0 L 568.0,723.0 L 566.0,721.0 L 566.0,703.0 L 567.0,702.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 398.0,678.0 L 398.0,693.0 L 434.0,693.0 L 435.0,694.0 L 435.0,767.0 L 451.0,767.0 L 451.0,694.0 L 452.0,693.0 L 489.0,693.0 L 489.0,679.0 L 488.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,678.0 L 250.0,692.0 L 251.0,692.0 L 252.0,693.0 L 339.0,693.0 L 339.0,691.0 L 340.0,690.0 L 340.0,679.0 L 339.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1134.0,683.0 L 1129.0,688.0 L 1129.0,689.0 L 1127.0,691.0 L 1127.0,693.0 L 1126.0,694.0 L 1126.0,696.0 L 1125.0,697.0 L 1125.0,712.0 L 1126.0,713.0 L 1126.0,715.0 L 1127.0,716.0 L 1128.0,719.0 L 1134.0,725.0 L 1135.0,725.0 L 1137.0,727.0 L 1139.0,727.0 L 1140.0,728.0 L 1143.0,728.0 L 1144.0,729.0 L 1185.0,729.0 L 1186.0,730.0 L 1191.0,730.0 L 1192.0,731.0 L 1193.0,731.0 L 1197.0,735.0 L 1197.0,739.0 L 1198.0,740.0 L 1198.0,742.0 L 1197.0,743.0 L 1197.0,746.0 L 1196.0,747.0 L 1196.0,748.0 L 1193.0,751.0 L 1192.0,751.0 L 1189.0,753.0 L 1184.0,753.0 L 1183.0,754.0 L 1151.0,754.0 L 1150.0,753.0 L 1143.0,753.0 L 1142.0,752.0 L 1139.0,752.0 L 1138.0,751.0 L 1135.0,751.0 L 1134.0,750.0 L 1131.0,750.0 L 1130.0,749.0 L 1128.0,749.0 L 1127.0,748.0 L 1125.0,748.0 L 1125.0,760.0 L 1124.0,761.0 L 1126.0,763.0 L 1128.0,763.0 L 1129.0,764.0 L 1131.0,764.0 L 1132.0,765.0 L 1134.0,765.0 L 1135.0,766.0 L 1139.0,766.0 L 1140.0,767.0 L 1146.0,767.0 L 1147.0,768.0 L 1186.0,768.0 L 1187.0,767.0 L 1191.0,767.0 L 1192.0,766.0 L 1195.0,766.0 L 1196.0,765.0 L 1198.0,765.0 L 1199.0,764.0 L 1201.0,764.0 L 1203.0,762.0 L 1204.0,762.0 L 1209.0,757.0 L 1209.0,756.0 L 1211.0,754.0 L 1211.0,753.0 L 1212.0,752.0 L 1212.0,750.0 L 1213.0,749.0 L 1213.0,734.0 L 1212.0,733.0 L 1212.0,730.0 L 1211.0,729.0 L 1211.0,728.0 L 1209.0,726.0 L 1209.0,725.0 L 1204.0,720.0 L 1203.0,720.0 L 1202.0,719.0 L 1201.0,719.0 L 1196.0,716.0 L 1192.0,716.0 L 1191.0,715.0 L 1148.0,715.0 L 1147.0,714.0 L 1145.0,714.0 L 1142.0,711.0 L 1142.0,710.0 L 1141.0,709.0 L 1141.0,699.0 L 1142.0,698.0 L 1142.0,697.0 L 1145.0,694.0 L 1146.0,694.0 L 1147.0,693.0 L 1150.0,693.0 L 1151.0,692.0 L 1190.0,692.0 L 1191.0,693.0 L 1196.0,693.0 L 1197.0,694.0 L 1201.0,694.0 L 1202.0,695.0 L 1204.0,695.0 L 1205.0,696.0 L 1209.0,696.0 L 1209.0,683.0 L 1206.0,681.0 L 1203.0,681.0 L 1202.0,680.0 L 1198.0,680.0 L 1197.0,679.0 L 1193.0,679.0 L 1192.0,678.0 L 1182.0,678.0 L 1181.0,677.0 L 1156.0,677.0 L 1155.0,678.0 L 1146.0,678.0 L 1145.0,679.0 L 1143.0,679.0 L 1142.0,680.0 L 1140.0,680.0 L 1139.0,681.0 L 1138.0,681.0 L 1137.0,682.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 714.0,691.0 L 714.0,692.0 L 711.0,695.0 L 711.0,696.0 L 709.0,698.0 L 709.0,699.0 L 708.0,700.0 L 708.0,702.0 L 707.0,703.0 L 707.0,706.0 L 706.0,707.0 L 706.0,711.0 L 705.0,712.0 L 705.0,734.0 L 706.0,735.0 L 706.0,739.0 L 707.0,740.0 L 707.0,743.0 L 708.0,744.0 L 708.0,745.0 L 709.0,746.0 L 709.0,747.0 L 710.0,748.0 L 711.0,751.0 L 713.0,753.0 L 713.0,754.0 L 718.0,759.0 L 719.0,759.0 L 721.0,761.0 L 722.0,761.0 L 723.0,762.0 L 724.0,762.0 L 729.0,765.0 L 731.0,765.0 L 732.0,766.0 L 735.0,766.0 L 736.0,767.0 L 741.0,767.0 L 742.0,768.0 L 775.0,768.0 L 776.0,767.0 L 781.0,767.0 L 782.0,766.0 L 785.0,766.0 L 786.0,765.0 L 788.0,765.0 L 789.0,764.0 L 790.0,764.0 L 791.0,763.0 L 792.0,763.0 L 793.0,762.0 L 796.0,761.0 L 798.0,759.0 L 799.0,759.0 L 805.0,753.0 L 805.0,752.0 L 807.0,750.0 L 807.0,749.0 L 809.0,746.0 L 809.0,744.0 L 811.0,741.0 L 811.0,737.0 L 812.0,736.0 L 812.0,711.0 L 811.0,710.0 L 811.0,706.0 L 810.0,705.0 L 810.0,703.0 L 809.0,702.0 L 809.0,700.0 L 808.0,699.0 L 808.0,698.0 L 806.0,696.0 L 806.0,695.0 L 803.0,692.0 L 803.0,691.0 L 799.0,687.0 L 798.0,687.0 L 793.0,683.0 L 792.0,683.0 L 791.0,682.0 L 789.0,682.0 L 786.0,680.0 L 783.0,680.0 L 782.0,679.0 L 779.0,679.0 L 778.0,678.0 L 770.0,678.0 L 769.0,677.0 L 748.0,677.0 L 747.0,678.0 L 739.0,678.0 L 738.0,679.0 L 734.0,679.0 L 733.0,680.0 L 731.0,680.0 L 730.0,681.0 L 728.0,681.0 L 727.0,682.0 L 726.0,682.0 L 725.0,683.0 L 722.0,684.0 L 720.0,686.0 L 719.0,686.0 Z M 725.0,702.0 L 730.0,697.0 L 731.0,697.0 L 733.0,695.0 L 735.0,695.0 L 738.0,693.0 L 742.0,693.0 L 743.0,692.0 L 772.0,692.0 L 773.0,693.0 L 777.0,693.0 L 778.0,694.0 L 781.0,694.0 L 782.0,695.0 L 785.0,696.0 L 787.0,698.0 L 788.0,698.0 L 791.0,701.0 L 791.0,702.0 L 793.0,704.0 L 793.0,705.0 L 794.0,706.0 L 794.0,708.0 L 795.0,709.0 L 795.0,712.0 L 796.0,713.0 L 796.0,733.0 L 795.0,734.0 L 795.0,738.0 L 794.0,739.0 L 793.0,742.0 L 791.0,744.0 L 791.0,745.0 L 788.0,748.0 L 787.0,748.0 L 785.0,750.0 L 784.0,750.0 L 781.0,752.0 L 778.0,752.0 L 777.0,753.0 L 770.0,753.0 L 769.0,754.0 L 747.0,754.0 L 746.0,753.0 L 740.0,753.0 L 739.0,752.0 L 737.0,752.0 L 736.0,751.0 L 734.0,751.0 L 733.0,750.0 L 732.0,750.0 L 730.0,748.0 L 729.0,748.0 L 725.0,744.0 L 725.0,743.0 L 722.0,738.0 L 722.0,734.0 L 721.0,733.0 L 721.0,713.0 L 722.0,712.0 L 722.0,709.0 L 723.0,708.0 L 723.0,706.0 L 724.0,705.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 95.0,678.0 L 95.0,766.0 L 96.0,767.0 L 111.0,767.0 L 111.0,737.0 L 112.0,736.0 L 171.0,736.0 L 172.0,735.0 L 175.0,735.0 L 176.0,734.0 L 178.0,734.0 L 179.0,733.0 L 180.0,733.0 L 182.0,731.0 L 183.0,731.0 L 186.0,728.0 L 187.0,728.0 L 187.0,727.0 L 190.0,724.0 L 190.0,723.0 L 193.0,718.0 L 193.0,715.0 L 194.0,714.0 L 194.0,702.0 L 193.0,701.0 L 193.0,696.0 L 192.0,695.0 L 192.0,693.0 L 191.0,692.0 L 190.0,689.0 L 188.0,687.0 L 188.0,686.0 L 185.0,683.0 L 184.0,683.0 L 182.0,681.0 L 181.0,681.0 L 180.0,680.0 L 178.0,680.0 L 177.0,679.0 L 174.0,679.0 L 173.0,678.0 L 168.0,678.0 L 167.0,677.0 L 156.0,677.0 L 155.0,678.0 L 135.0,678.0 L 134.0,677.0 L 132.0,677.0 L 131.0,678.0 L 108.0,678.0 L 107.0,677.0 L 105.0,677.0 L 104.0,678.0 Z M 111.0,693.0 L 112.0,692.0 L 169.0,692.0 L 170.0,693.0 L 172.0,693.0 L 177.0,698.0 L 177.0,699.0 L 178.0,700.0 L 178.0,712.0 L 177.0,713.0 L 177.0,715.0 L 176.0,716.0 L 176.0,717.0 L 174.0,719.0 L 173.0,719.0 L 170.0,721.0 L 167.0,721.0 L 166.0,722.0 L 154.0,722.0 L 153.0,721.0 L 152.0,722.0 L 115.0,722.0 L 114.0,721.0 L 113.0,722.0 L 111.0,720.0 Z" fill="#FFFFFF" fill-rule="evenodd"/><rect x="250" y="678" width="16" height="90" fill="#FFFFFF"/></g>
<path d="M 1084.0,678.0 L 1063.0,678.0 L 1056.0,685.0 L 1056.0,686.0 L 1049.0,693.0 L 1049.0,694.0 L 1042.0,701.0 L 1042.0,702.0 L 1036.0,708.0 L 1036.0,709.0 L 1035.0,710.0 L 1035.0,711.0 L 1033.0,713.0 L 1033.0,714.0 L 1032.0,715.0 L 1032.0,716.0 L 1031.0,717.0 L 1031.0,718.0 L 1030.0,719.0 L 1030.0,720.0 L 1028.0,723.0 L 1028.0,725.0 L 1026.0,728.0 L 1026.0,730.0 L 1025.0,731.0 L 1025.0,733.0 L 1024.0,734.0 L 1024.0,738.0 L 1023.0,739.0 L 1023.0,743.0 L 1022.0,744.0 L 1022.0,752.0 L 1021.0,753.0 L 1021.0,766.0 L 1022.0,767.0 L 1038.0,767.0 L 1038.0,737.0 L 1039.0,736.0 L 1039.0,734.0 L 1040.0,733.0 L 1040.0,732.0 L 1041.0,731.0 L 1041.0,730.0 L 1043.0,728.0 L 1043.0,727.0 L 1046.0,724.0 L 1046.0,723.0 L 1052.0,717.0 L 1052.0,716.0 L 1060.0,708.0 L 1060.0,707.0 L 1065.0,702.0 L 1065.0,701.0 L 1072.0,694.0 L 1072.0,693.0 L 1077.0,688.0 L 1077.0,687.0 L 1082.0,682.0 L 1082.0,681.0 L 1084.0,679.0 L 1085.0,679.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 975.0,678.0 L 975.0,679.0 L 979.0,683.0 L 979.0,684.0 L 983.0,688.0 L 983.0,689.0 L 990.0,696.0 L 990.0,697.0 L 996.0,703.0 L 996.0,704.0 L 1002.0,710.0 L 1002.0,711.0 L 1010.0,719.0 L 1010.0,720.0 L 1014.0,724.0 L 1014.0,725.0 L 1017.0,728.0 L 1017.0,729.0 L 1018.0,730.0 L 1018.0,731.0 L 1019.0,732.0 L 1020.0,732.0 L 1020.0,729.0 L 1021.0,728.0 L 1021.0,726.0 L 1022.0,725.0 L 1022.0,722.0 L 1023.0,721.0 L 1023.0,715.0 L 1024.0,714.0 L 1023.0,713.0 L 1023.0,709.0 L 1022.0,708.0 L 1022.0,707.0 L 1021.0,706.0 L 1021.0,705.0 L 1016.0,700.0 L 1016.0,699.0 L 1010.0,693.0 L 1010.0,692.0 L 1003.0,685.0 L 1003.0,684.0 L 997.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 9.9 KiB

@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst">
<!-- Petrolyst's wordmark alone: petrolyst-web/design/brand/02_Logo/Vector_Master/
petrolyst_wordmark_fullcolor.svg with the letters white for the dark chrome (PD-26); the Y keeps its original gradient. -->
<defs>
<linearGradient id="wordYGradient" x1="945" y1="660" x2="1090" y2="780" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#009688"/>
<stop offset="100%" stop-color="#00B6E6"/>
</linearGradient>
</defs>
<g fill="#FFFFFF">
<path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,715.0 L 250.0,729.0 L 266.0,729.0 L 267.0,730.0 L 301.0,730.0 L 302.0,729.0 L 307.0,729.0 L 308.0,730.0 L 325.0,730.0 L 326.0,729.0 L 340.0,729.0 L 340.0,716.0 L 339.0,715.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1260.0,678.0 L 1260.0,692.0 L 1264.0,692.0 L 1265.0,693.0 L 1296.0,693.0 L 1297.0,694.0 L 1297.0,767.0 L 1313.0,767.0 L 1313.0,694.0 L 1314.0,693.0 L 1351.0,693.0 L 1351.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 867.0,678.0 L 867.0,767.0 L 951.0,767.0 L 951.0,753.0 L 950.0,752.0 L 883.0,752.0 L 882.0,751.0 L 882.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 551.0,678.0 L 551.0,767.0 L 566.0,767.0 L 566.0,743.0 L 567.0,742.0 L 567.0,739.0 L 566.0,738.0 L 567.0,737.0 L 604.0,737.0 L 614.0,747.0 L 614.0,748.0 L 622.0,756.0 L 622.0,757.0 L 630.0,765.0 L 630.0,766.0 L 631.0,767.0 L 651.0,767.0 L 651.0,766.0 L 649.0,764.0 L 649.0,763.0 L 642.0,756.0 L 642.0,755.0 L 628.0,741.0 L 628.0,740.0 L 626.0,738.0 L 628.0,736.0 L 630.0,736.0 L 631.0,735.0 L 633.0,735.0 L 634.0,734.0 L 636.0,734.0 L 638.0,732.0 L 639.0,732.0 L 645.0,726.0 L 645.0,725.0 L 648.0,720.0 L 648.0,718.0 L 649.0,717.0 L 649.0,713.0 L 650.0,712.0 L 650.0,703.0 L 649.0,702.0 L 649.0,698.0 L 648.0,697.0 L 648.0,695.0 L 647.0,694.0 L 646.0,691.0 L 644.0,689.0 L 644.0,688.0 L 640.0,684.0 L 639.0,684.0 L 637.0,682.0 L 636.0,682.0 L 633.0,680.0 L 631.0,680.0 L 630.0,679.0 L 626.0,679.0 L 625.0,678.0 Z M 567.0,694.0 L 568.0,693.0 L 625.0,693.0 L 626.0,694.0 L 629.0,695.0 L 632.0,698.0 L 632.0,699.0 L 633.0,700.0 L 633.0,713.0 L 632.0,714.0 L 632.0,716.0 L 630.0,718.0 L 630.0,719.0 L 629.0,720.0 L 628.0,720.0 L 626.0,722.0 L 622.0,722.0 L 621.0,723.0 L 568.0,723.0 L 566.0,721.0 L 566.0,703.0 L 567.0,702.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 398.0,678.0 L 398.0,693.0 L 434.0,693.0 L 435.0,694.0 L 435.0,767.0 L 451.0,767.0 L 451.0,694.0 L 452.0,693.0 L 489.0,693.0 L 489.0,679.0 L 488.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 250.0,678.0 L 250.0,692.0 L 251.0,692.0 L 252.0,693.0 L 339.0,693.0 L 339.0,691.0 L 340.0,690.0 L 340.0,679.0 L 339.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 1134.0,683.0 L 1129.0,688.0 L 1129.0,689.0 L 1127.0,691.0 L 1127.0,693.0 L 1126.0,694.0 L 1126.0,696.0 L 1125.0,697.0 L 1125.0,712.0 L 1126.0,713.0 L 1126.0,715.0 L 1127.0,716.0 L 1128.0,719.0 L 1134.0,725.0 L 1135.0,725.0 L 1137.0,727.0 L 1139.0,727.0 L 1140.0,728.0 L 1143.0,728.0 L 1144.0,729.0 L 1185.0,729.0 L 1186.0,730.0 L 1191.0,730.0 L 1192.0,731.0 L 1193.0,731.0 L 1197.0,735.0 L 1197.0,739.0 L 1198.0,740.0 L 1198.0,742.0 L 1197.0,743.0 L 1197.0,746.0 L 1196.0,747.0 L 1196.0,748.0 L 1193.0,751.0 L 1192.0,751.0 L 1189.0,753.0 L 1184.0,753.0 L 1183.0,754.0 L 1151.0,754.0 L 1150.0,753.0 L 1143.0,753.0 L 1142.0,752.0 L 1139.0,752.0 L 1138.0,751.0 L 1135.0,751.0 L 1134.0,750.0 L 1131.0,750.0 L 1130.0,749.0 L 1128.0,749.0 L 1127.0,748.0 L 1125.0,748.0 L 1125.0,760.0 L 1124.0,761.0 L 1126.0,763.0 L 1128.0,763.0 L 1129.0,764.0 L 1131.0,764.0 L 1132.0,765.0 L 1134.0,765.0 L 1135.0,766.0 L 1139.0,766.0 L 1140.0,767.0 L 1146.0,767.0 L 1147.0,768.0 L 1186.0,768.0 L 1187.0,767.0 L 1191.0,767.0 L 1192.0,766.0 L 1195.0,766.0 L 1196.0,765.0 L 1198.0,765.0 L 1199.0,764.0 L 1201.0,764.0 L 1203.0,762.0 L 1204.0,762.0 L 1209.0,757.0 L 1209.0,756.0 L 1211.0,754.0 L 1211.0,753.0 L 1212.0,752.0 L 1212.0,750.0 L 1213.0,749.0 L 1213.0,734.0 L 1212.0,733.0 L 1212.0,730.0 L 1211.0,729.0 L 1211.0,728.0 L 1209.0,726.0 L 1209.0,725.0 L 1204.0,720.0 L 1203.0,720.0 L 1202.0,719.0 L 1201.0,719.0 L 1196.0,716.0 L 1192.0,716.0 L 1191.0,715.0 L 1148.0,715.0 L 1147.0,714.0 L 1145.0,714.0 L 1142.0,711.0 L 1142.0,710.0 L 1141.0,709.0 L 1141.0,699.0 L 1142.0,698.0 L 1142.0,697.0 L 1145.0,694.0 L 1146.0,694.0 L 1147.0,693.0 L 1150.0,693.0 L 1151.0,692.0 L 1190.0,692.0 L 1191.0,693.0 L 1196.0,693.0 L 1197.0,694.0 L 1201.0,694.0 L 1202.0,695.0 L 1204.0,695.0 L 1205.0,696.0 L 1209.0,696.0 L 1209.0,683.0 L 1206.0,681.0 L 1203.0,681.0 L 1202.0,680.0 L 1198.0,680.0 L 1197.0,679.0 L 1193.0,679.0 L 1192.0,678.0 L 1182.0,678.0 L 1181.0,677.0 L 1156.0,677.0 L 1155.0,678.0 L 1146.0,678.0 L 1145.0,679.0 L 1143.0,679.0 L 1142.0,680.0 L 1140.0,680.0 L 1139.0,681.0 L 1138.0,681.0 L 1137.0,682.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 714.0,691.0 L 714.0,692.0 L 711.0,695.0 L 711.0,696.0 L 709.0,698.0 L 709.0,699.0 L 708.0,700.0 L 708.0,702.0 L 707.0,703.0 L 707.0,706.0 L 706.0,707.0 L 706.0,711.0 L 705.0,712.0 L 705.0,734.0 L 706.0,735.0 L 706.0,739.0 L 707.0,740.0 L 707.0,743.0 L 708.0,744.0 L 708.0,745.0 L 709.0,746.0 L 709.0,747.0 L 710.0,748.0 L 711.0,751.0 L 713.0,753.0 L 713.0,754.0 L 718.0,759.0 L 719.0,759.0 L 721.0,761.0 L 722.0,761.0 L 723.0,762.0 L 724.0,762.0 L 729.0,765.0 L 731.0,765.0 L 732.0,766.0 L 735.0,766.0 L 736.0,767.0 L 741.0,767.0 L 742.0,768.0 L 775.0,768.0 L 776.0,767.0 L 781.0,767.0 L 782.0,766.0 L 785.0,766.0 L 786.0,765.0 L 788.0,765.0 L 789.0,764.0 L 790.0,764.0 L 791.0,763.0 L 792.0,763.0 L 793.0,762.0 L 796.0,761.0 L 798.0,759.0 L 799.0,759.0 L 805.0,753.0 L 805.0,752.0 L 807.0,750.0 L 807.0,749.0 L 809.0,746.0 L 809.0,744.0 L 811.0,741.0 L 811.0,737.0 L 812.0,736.0 L 812.0,711.0 L 811.0,710.0 L 811.0,706.0 L 810.0,705.0 L 810.0,703.0 L 809.0,702.0 L 809.0,700.0 L 808.0,699.0 L 808.0,698.0 L 806.0,696.0 L 806.0,695.0 L 803.0,692.0 L 803.0,691.0 L 799.0,687.0 L 798.0,687.0 L 793.0,683.0 L 792.0,683.0 L 791.0,682.0 L 789.0,682.0 L 786.0,680.0 L 783.0,680.0 L 782.0,679.0 L 779.0,679.0 L 778.0,678.0 L 770.0,678.0 L 769.0,677.0 L 748.0,677.0 L 747.0,678.0 L 739.0,678.0 L 738.0,679.0 L 734.0,679.0 L 733.0,680.0 L 731.0,680.0 L 730.0,681.0 L 728.0,681.0 L 727.0,682.0 L 726.0,682.0 L 725.0,683.0 L 722.0,684.0 L 720.0,686.0 L 719.0,686.0 Z M 725.0,702.0 L 730.0,697.0 L 731.0,697.0 L 733.0,695.0 L 735.0,695.0 L 738.0,693.0 L 742.0,693.0 L 743.0,692.0 L 772.0,692.0 L 773.0,693.0 L 777.0,693.0 L 778.0,694.0 L 781.0,694.0 L 782.0,695.0 L 785.0,696.0 L 787.0,698.0 L 788.0,698.0 L 791.0,701.0 L 791.0,702.0 L 793.0,704.0 L 793.0,705.0 L 794.0,706.0 L 794.0,708.0 L 795.0,709.0 L 795.0,712.0 L 796.0,713.0 L 796.0,733.0 L 795.0,734.0 L 795.0,738.0 L 794.0,739.0 L 793.0,742.0 L 791.0,744.0 L 791.0,745.0 L 788.0,748.0 L 787.0,748.0 L 785.0,750.0 L 784.0,750.0 L 781.0,752.0 L 778.0,752.0 L 777.0,753.0 L 770.0,753.0 L 769.0,754.0 L 747.0,754.0 L 746.0,753.0 L 740.0,753.0 L 739.0,752.0 L 737.0,752.0 L 736.0,751.0 L 734.0,751.0 L 733.0,750.0 L 732.0,750.0 L 730.0,748.0 L 729.0,748.0 L 725.0,744.0 L 725.0,743.0 L 722.0,738.0 L 722.0,734.0 L 721.0,733.0 L 721.0,713.0 L 722.0,712.0 L 722.0,709.0 L 723.0,708.0 L 723.0,706.0 L 724.0,705.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<path d="M 95.0,678.0 L 95.0,766.0 L 96.0,767.0 L 111.0,767.0 L 111.0,737.0 L 112.0,736.0 L 171.0,736.0 L 172.0,735.0 L 175.0,735.0 L 176.0,734.0 L 178.0,734.0 L 179.0,733.0 L 180.0,733.0 L 182.0,731.0 L 183.0,731.0 L 186.0,728.0 L 187.0,728.0 L 187.0,727.0 L 190.0,724.0 L 190.0,723.0 L 193.0,718.0 L 193.0,715.0 L 194.0,714.0 L 194.0,702.0 L 193.0,701.0 L 193.0,696.0 L 192.0,695.0 L 192.0,693.0 L 191.0,692.0 L 190.0,689.0 L 188.0,687.0 L 188.0,686.0 L 185.0,683.0 L 184.0,683.0 L 182.0,681.0 L 181.0,681.0 L 180.0,680.0 L 178.0,680.0 L 177.0,679.0 L 174.0,679.0 L 173.0,678.0 L 168.0,678.0 L 167.0,677.0 L 156.0,677.0 L 155.0,678.0 L 135.0,678.0 L 134.0,677.0 L 132.0,677.0 L 131.0,678.0 L 108.0,678.0 L 107.0,677.0 L 105.0,677.0 L 104.0,678.0 Z M 111.0,693.0 L 112.0,692.0 L 169.0,692.0 L 170.0,693.0 L 172.0,693.0 L 177.0,698.0 L 177.0,699.0 L 178.0,700.0 L 178.0,712.0 L 177.0,713.0 L 177.0,715.0 L 176.0,716.0 L 176.0,717.0 L 174.0,719.0 L 173.0,719.0 L 170.0,721.0 L 167.0,721.0 L 166.0,722.0 L 154.0,722.0 L 153.0,721.0 L 152.0,722.0 L 115.0,722.0 L 114.0,721.0 L 113.0,722.0 L 111.0,720.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
<rect x="250" y="678" width="16" height="90" fill="#FFFFFF"/>
</g>
<path d="M 1084.0,678.0 L 1063.0,678.0 L 1056.0,685.0 L 1056.0,686.0 L 1049.0,693.0 L 1049.0,694.0 L 1042.0,701.0 L 1042.0,702.0 L 1036.0,708.0 L 1036.0,709.0 L 1035.0,710.0 L 1035.0,711.0 L 1033.0,713.0 L 1033.0,714.0 L 1032.0,715.0 L 1032.0,716.0 L 1031.0,717.0 L 1031.0,718.0 L 1030.0,719.0 L 1030.0,720.0 L 1028.0,723.0 L 1028.0,725.0 L 1026.0,728.0 L 1026.0,730.0 L 1025.0,731.0 L 1025.0,733.0 L 1024.0,734.0 L 1024.0,738.0 L 1023.0,739.0 L 1023.0,743.0 L 1022.0,744.0 L 1022.0,752.0 L 1021.0,753.0 L 1021.0,766.0 L 1022.0,767.0 L 1038.0,767.0 L 1038.0,737.0 L 1039.0,736.0 L 1039.0,734.0 L 1040.0,733.0 L 1040.0,732.0 L 1041.0,731.0 L 1041.0,730.0 L 1043.0,728.0 L 1043.0,727.0 L 1046.0,724.0 L 1046.0,723.0 L 1052.0,717.0 L 1052.0,716.0 L 1060.0,708.0 L 1060.0,707.0 L 1065.0,702.0 L 1065.0,701.0 L 1072.0,694.0 L 1072.0,693.0 L 1077.0,688.0 L 1077.0,687.0 L 1082.0,682.0 L 1082.0,681.0 L 1084.0,679.0 L 1085.0,679.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
<path d="M 975.0,678.0 L 975.0,679.0 L 979.0,683.0 L 979.0,684.0 L 983.0,688.0 L 983.0,689.0 L 990.0,696.0 L 990.0,697.0 L 996.0,703.0 L 996.0,704.0 L 1002.0,710.0 L 1002.0,711.0 L 1010.0,719.0 L 1010.0,720.0 L 1014.0,724.0 L 1014.0,725.0 L 1017.0,728.0 L 1017.0,729.0 L 1018.0,730.0 L 1018.0,731.0 L 1019.0,732.0 L 1020.0,732.0 L 1020.0,729.0 L 1021.0,728.0 L 1021.0,726.0 L 1022.0,725.0 L 1022.0,722.0 L 1023.0,721.0 L 1023.0,715.0 L 1024.0,714.0 L 1023.0,713.0 L 1023.0,709.0 L 1022.0,708.0 L 1022.0,707.0 L 1021.0,706.0 L 1021.0,705.0 L 1016.0,700.0 L 1016.0,699.0 L 1010.0,693.0 L 1010.0,692.0 L 1003.0,685.0 L 1003.0,684.0 L 997.0,678.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+37 -5
View File
@@ -1,13 +1,45 @@
# dashboards/ # dashboards/
Exported Superset dashboards and the operator scripts that import them. Not baked into the Superset dashboards as code and the operator scripts that import them. Not baked into the
image, so changes here do not rebuild or restart Superset. Empty until the first fuel image, so changes here do not rebuild or restart Superset.
dashboards exist.
- `atoma/`: ATOMA's two dashboards for the Petrolyst app (PD-12, PD-13; SPEC.md §10):
`overview` (fleet totals) and `site` (one site, its four sections as tabs), named `atoma-overview` and `atoma-site` until 30 Oct. The
app embeds them; admins open them in Superset, with the filter column. `SPEC.md` is the
design; `build_bundle.py` generates the import bundle and `import_bundle.py` imports it
(overwriting the objects the bundle defines).
Build and import:
docker cp atoma/import_bundle.py petrolyst-superset-1:/tmp/
docker exec petrolyst-superset-1 /app/.venv/bin/python /tmp/import_bundle.py --show-database
python3 atoma/build_bundle.py --database-uuid <printed uuid> --out atoma-dashboards.zip
docker cp atoma-dashboards.zip petrolyst-superset-1:/tmp/
docker exec petrolyst-superset-1 /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip --user <username>
Exit status 2 means a dataset's columns differ from its view: regenerate `atoma/columns.tsv`
(command in its header), rebuild and import again.
An import never deletes. Where they exist, delete these by hand after importing: the
dashboards from before 28 Oct ("ATOMA - Sites", "ATOMA - Site Detail") and their charts M1,
M3, M4 and M5; the ATOMA dashboard (slug `atoma`, retired 29 Oct with PD-13) and its chart D0
"Site"; the one-tab dashboards `atoma-site-summary`, `-fuel`, `-runtime` and `-baseline`
(replaced by `atoma-site` on 29 Oct); and the role "ATOMA Reader".
The dashboards get no role; the app shows them with guest passes (see "Embedding" in the
parent README; local stack only). Add `--embedded-out FILE --embed-domain <app origin>` to the
import to make them embeddable from the app's origin; FILE then receives
`{slug: embedding id}` for the Petrolyst server. The ids stay the same across imports. Local
stack:
docker exec fmdb-scratch-superset /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip \
--user admin --embedded-out /tmp/embedded-dashboards.json --embed-domain http://127.0.0.1:8098
docker cp fmdb-scratch-superset:/tmp/embedded-dashboards.json <Petrolyst>/.local/fmdb-scratch/
Export from a running instance (inside the container, as the server's deploy user): Export from a running instance (inside the container, as the server's deploy user):
docker exec fuel-monitoring-superset-1 superset export-dashboards -f /tmp/dashboards.zip docker exec petrolyst-superset-1 superset export-dashboards -f /tmp/dashboards.zip
docker cp fuel-monitoring-superset-1:/tmp/dashboards.zip . docker cp petrolyst-superset-1:/tmp/dashboards.zip .
Import is the reverse with `superset import-dashboards -p <zip> -u <username>`. The Import is the reverse with `superset import-dashboards -p <zip> -u <username>`. The
username must be an existing Superset user, which here means someone who has signed in username must be an existing Superset user, which here means someone who has signed in
+440 -121
View File
@@ -1,13 +1,23 @@
# ATOMA dashboards: Sites Map and Site Detail # ATOMA dashboards: Overview and Site, for the Petrolyst app
**Status: proposed, not built.** **Status (30 Oct 2026, PD-21: every customer sees these two, limited by row; slugs `overview` and `site`, formerly
- `build_bundle.py` (phase P2) will generate both dashboards, every dataset and every chart from `atoma-overview` and `atoma-site`, which the rest of this file still uses). Two dashboards (§10), built and
this specification, as one Superset import bundle. tested on a local Superset; not imported live.** The Petrolyst app embeds them, and admins open
them in Superset with the filter column.
- `build_bundle.py` generates the dashboards, every dataset and every chart from this
specification, as one Superset import bundle; `import_bundle.py` imports it (§6).
- **Retired 29 Oct (PD-13):** the "ATOMA" dashboard (slug `atoma`) with its Map tab (the React
fleet-map view of petrolyst-platform) and Insights tab, its D0 header row, and the Superset
reader setup ("ATOMA Reader", `reader.css`). ATOMA's users use the Petrolyst app. §2, §3, §9 and
the readers' part of §6 describe them and stay as history. §4's data rules and four tabs are
current: they are `atoma-site`'s tabs, without D0 (the app shows the site's header).
- Earlier changes are marked *(P2)* (local tests of 24 Oct, §8) and *(28 Oct)* (one dashboard
with the fleet-map view, §9).
- Nothing is imported into the live Superset without the owner's go-ahead (phase P3). - Nothing is imported into the live Superset without the owner's go-ahead (phase P3).
**Datasets.** The `public.v_*` views of **Datasets.** The `public.v_*` views of
`stacks/fuel-monitoring/fuel-db/migrations/0003_bi_views.sql`, read through Trino as `stacks/fuel-monitoring/fuel-db/migrations/0003_bi_views.sql`, read from fuel-db directly (database
`fuel.public`. `fuel`, schema `public`) as the read-only role `fuel_ro`.
**Design and rationale** live in the fuel-monitoring corpus, in **Design and rationale** live in the fuel-monitoring corpus, in
`.context/plans/2026-10-24-fuel-db-baseline-dashboard.md`: `.context/plans/2026-10-24-fuel-db-baseline-dashboard.md`:
@@ -15,72 +25,103 @@
- the site-specific facts left out here (positions, distances, pilot sites) are there too; - the site-specific facts left out here (positions, distances, pilot sites) are there too;
- Q-numbers refer to its §7, and phases P0–P5 to its §8. - Q-numbers refer to its §7, and phases P0–P5 to its §8.
Two dashboards replace the earlier tab-per-site design; nothing in them is specific to a site. Nothing in the dashboards is specific to a site.
## 1. Capabilities of the installed Superset 6.1.0 ## 1. Capabilities of the installed Superset 6.1.0
Source: read-only inspection of `config.py`, `superset_config.py` and the frontend bundle on Source: read-only inspection of `config.py`, `superset_config.py` and the frontend bundle on
24 Oct 2026. Items marked *(local test)* are confirmed in P2 before the design relies on them. 24 Oct 2026. The former *(local test)* items were tested in P2 (§8); rows marked *(P2)* changed as a result.
| Requirement | What 6.1.0 provides | Choice | | Requirement | What 6.1.0 provides | Choice |
|---|---|---| |---|---|---|
| Map chart | `deck_scatter` (deck.gl Scatterplot): a modern plugin with the InteractiveChart behaviour. Also: deck_multi, deck_screengrid / hex / grid, country_map, world_map, cartodiagram, legacy `mapbox` | **deck.gl Scatterplot** | | Map chart | `deck_scatter` (deck.gl Scatterplot): a modern plugin with the InteractiveChart behaviour. Also: deck_multi, deck_screengrid / hex / grid, country_map, world_map, cartodiagram, legacy `mapbox` | **deck.gl Scatterplot** |
| Basemap | `DECKGL_BASE_MAP` unset → OSM raster (`tile.openstreetmap.org`), OSM Swiss topo, Mapbox styles. `MAPBOX_API_KEY` is empty, so the Mapbox styles fail. CSP `connect-src` already allows the OSM host; Traefik sets no CSP | set `DECKGL_BASE_MAP` to **OSM only**; "© OpenStreetMap contributors" in a Markdown under the map | | Basemap | `DECKGL_BASE_MAP` unset → OSM raster (`tile.openstreetmap.org`), OSM Swiss topo, Mapbox styles. `MAPBOX_API_KEY` is empty, so the Mapbox styles fail. CSP `connect-src` already allows the OSM host; Traefik sets no CSP | set `DECKGL_BASE_MAP` to **OSM only**; "© OpenStreetMap contributors" in a Markdown under the map |
| OSM terms | no key, no cost; tile usage policy: light use, attribution, no bulk download, a valid Referer (ours sends the origin) | fine for an internal user base; viewers' browsers reveal the viewed area (tile coordinates) and their IP to OSMF, not our data. Self-hosted tiles later if needed | | OSM terms | no key, no cost; tile usage policy: light use, attribution, no bulk download, a valid Referer (ours sends the origin) | fine for an internal user base; viewers' browsers reveal the viewed area (tile coordinates) and their IP to OSMF, not our data. Self-hosted tiles later if needed |
| Tooltip | "Tooltip contents" (drag columns) plus "Customize tooltips template" (Handlebars). `js_tooltip` exists but `ENABLE_JAVASCRIPT_CONTROLS` is False | contents and template; **no JavaScript** | | Tooltip | "Tooltip contents" (drag columns) plus "Customize tooltips template" (Handlebars). `js_tooltip` exists but `ENABLE_JAVASCRIPT_CONTROLS` is False. The contents show column names, not labels, and leave out the colour column | **contents only** *(P2)*: short tooltip-only calculated columns (`code`, `name`, `l_per_h`, `method`, `status`, `validity`, `latest_record`); no template, no JavaScript |
| Marker colour | "Categorical color" by a dimension, from the colour scheme; a fixed colour per label through the dashboard's `label_colors` *(local test)* | colour by `baseline_status_label`. Fallback: `deck_multi` with one fixed-colour scatter layer per status | | Marker colour | "Categorical color" by a dimension, from the colour scheme; a fixed colour per label through the dashboard's `label_colors` (confirmed in P2) | colour by `baseline_status_label` |
| Marker size | fixed radius, with minimum = maximum pixel radius | uniform | | Marker size | fixed radius, with minimum = maximum pixel radius. No outline: the layer is drawn with `stroked: false` | uniform, no outline *(P2)* |
| Text cards | The Handlebars chart compiles its template with `eval`; Superset's own CSP (`script-src 'self' 'strict-dynamic'` + nonce) blocks that, so the chart shows a CSP error instead *(P2)* | **no Handlebars**: one-row tables. The CSP stays as it is |
| Chart descriptions | "Show description" (`expanded_slices`) never renders in 6.1: the dashboard loads `description_markeddown`, the chart holder reads `description_markdown` *(P2)* | descriptions stay on the charts; the visible reading notes are a Markdown block at the top of each tab |
| Viewport | `autozoom` fits the filtered points | on | | Viewport | `autozoom` fits the filtered points | on |
| Clustering | none in deck.gl; only the legacy `mapbox` chart (`clustering_radius`, needs a Mapbox key) | **no clustering** (§3) | | Clustering | none in deck.gl; only the legacy `mapbox` chart (`clustering_radius`, needs a Mapbox key) | **no clustering** (§3) |
| Marker click | emits a **cross-filter** on the latitude and longitude columns: the exact point, or a box around the click that shrinks as you zoom in. It applies to other charts on the same dashboard | cross-filters the Sites list | | Marker click | emits a **cross-filter** on the latitude and longitude columns: the exact point, or a box around the click that shrinks as you zoom in. It applies to other charts on the same dashboard | cross-filters the Sites list |
| Navigation | **no chart-to-dashboard navigation.** A dashboard reads `?native_filters=<rison>` into preselected native filter values (also `native_filters_key`). Table cells render sanitized HTML (`allow_render_html`) | an "Open site detail" link column in the Sites list | | Navigation | **no chart-to-dashboard navigation.** A dashboard reads `?native_filters=<rison>` into preselected native filter values (also `native_filters_key`). Table cells render sanitized HTML (`allow_render_html`) | an "Open site detail" link column in the Sites list |
| Native filters | Select (single or multi, required, searchable, "first value by default"), Time range; scoping per chart and tab; cross-filter scoping per chart | §4, §5 | | Native filters | Select (single or multi, required, searchable, "first value by default"), Time range; scoping per chart and tab; cross-filter scoping per chart | §4, §5 |
| Markdown iframe *(28 Oct)* | `HTML_SANITIZATION` is on and strips an iframe from a Markdown block; `HTML_SANITIZATION_SCHEMA_EXTENSIONS` lets named tags and attributes through. The CSP (`default-src 'self'`, no `frame-src`) allows frames from this origin only | allow `iframe` with `src`, `title`, `loading`, `style`; the fleet-map view is served on the same origin under `/views` (§3) |
| Deep link into a tab *(28 Oct)* | `#TAB-<id>` after the URL opens that tab; it combines with `?native_filters=` (tested 28 Oct) | the "Site details" button opens the Site details tab with the site preset |
*(28 Oct)* The map moved out of Superset into the fleet-map view (§3). The rows Map chart,
Basemap, Tooltip, Marker colour, Marker size, Viewport, Clustering and Marker click describe the
deck.gl option the dashboard used until then; the view's choices are in §3. The Navigation row
still holds: the view builds the same `?native_filters=` link.
No custom frontend and no patch are needed. No custom frontend and no patch are needed.
## 2. Navigation flow ## 2. Navigation flow *(retired, PD-13: history)*
*(28 Oct)* One dashboard, two top-level tabs. *(29 Oct)* They are named **Map** (`TAB-fleet`) and
**Insights** (`TAB-site`), the dashboard's lenses; earlier text in this document calls them Fleet and
Site details. Readers see them as a lens switch, centred in the navbar (§6).
``` ```
ATOMA — Sites (landing, slug atoma-sites) ATOMA (slug atoma)
filters: Site search · Zone · Province · Pilot status (default: active) · Baseline status filters, in Superset's left column: Site (exactly one, searchable) · Viewing period
M1 map: markers fitted to the filtered sites ──hover──► tooltip — both out of scope on Map; readers have no column there (§6)
└─click marker──► cross-filter: M3 Sites list shows that site (or the few under the click) [Map] the fleet-map view (iframe, /views/fleet-map/), the page's full width
M3 Sites list ─────────────"Open site detail"──► ATOMA — Site Detail ?native_filters=(site preset) towers fitted to the placed sites ──hover or click──► popup
M4/M5 Sites without an accepted location ──────"Open site detail"──► same "Find a site" (code or name) ──► the map flies to it ──► its popup
ATOMA — Site Detail (slug atoma-site-detail) └─"Site details"──► /superset/dashboard/atoma/?native_filters=(site preset)#TAB-site
filters: Site (exactly one, searchable) · Viewing period sites without an accepted location: counted in the map's key ("Choose them in Insights",
tabs: Summary · Fuel & Deliveries · Runtime & Electricity · Baseline & Evidence the view's tab_label parameter); chosen in the Site filter
header link "← Sites map" [Insights] D0 site header, then the tabs
Summary · Fuel & Deliveries · Runtime & Electricity · Baseline & Evidence
(a row of section buttons, not a second row of tabs: dashboard.css)
``` ```
## 3. Sites Map (landing dashboard) The filters sit in Superset's left column (`filter_bar_orientation: VERTICAL`) *(owner,
29 Oct; `build_bundle.py` checks it)*. A filter shows in the column on the tabs where it is in
scope; the others are listed under "Filters out of scope" (the Viewing period on Summary, for
example). No filter applies to the Fleet tab, so readers have no column there and the map takes
the full width (`reader.css`, §6); admins keep the column on both tabs. The column opens
collapsed, a 32 px strip with an expand button (`FILTERBAR_CLOSED_BY_DEFAULT`,
`superset_config.py`; owner, 29 Oct); the selected site shows in the Site table above the
sections. So for a reader the tabs move sideways by 32 px between Fleet and Site details, or by
the column's width once it is expanded; the owner accepted that.
From 28 to 29 Oct the filters sat in a row above the tabs (`HORIZONTAL`). On the Fleet tab that
row held only an empty "More filters", and hiding it there made the tabs jump down on Site
details. Superset 6.1 has no per-tab setting for the filter bar; it hides it only per link
(`?show_filters=0`).
Until 28 Oct there were two dashboards: "ATOMA - Sites" (slug `atoma-sites`: a deck.gl map, five
filters, a sites list) and "ATOMA - Site Detail" (slug `atoma-site-detail`), linked by "Open site
detail" and "← Sites map".
## 3. Fleet tab: the fleet-map view *(28 Oct; retired, PD-13: history)*
The Fleet tab holds one Markdown block with an iframe of the fleet-map view, a page of
petrolyst-platform (`views/src/fleet-map/`) served under `/views/` on Superset's own origin. The
view reads the read service's `site_status` operation (`/views/api/v1/site-status`, from
`public.v_site`). It is not a Superset chart: it has no dataset and no native filter, and both
filters are out of scope on this tab.
``` ```
[Filter bar: Site search · Zone · Province · Pilot status · Baseline status] [Fleet tab: MARKDOWN-fleet-map, w12 h100]
[M1 Map (deck.gl Scatterplot) ................................ w8][M2 Status key + attribution w4] <iframe src="/views/fleet-map/?detail=/superset/dashboard/atoma/&filter=NATIVE_FILTER-site&tab=TAB-site">
[M3 Sites list (table with link column) ..................................................... w12]
[M4 Sites without accepted location: count w3][M5 Sites without accepted location (table) .. w9]
``` ```
| # | Question | Type | Dataset | Content | Missing or insufficient | | Part | Content | Missing or insufficient |
|---|---|---|---|---|---| |---|---|---|
| M1 | Where are the sites, and what is each one's baseline status? | deck.gl Scatterplot; lat/lon from `latitude`, `longitude`; uniform size; colour = `baseline_status_label`; autozoom | v_site | tooltip: `site_code`, `site_name`, `lph_estimate` ("not available" when NULL), `lph_method`, `baseline_status_label`, `baseline_validity_note` (when set), `latest_record_on` | sites without an accepted location have NULL lat/lon and are not plotted; M4/M5 show them | | Map | MapLibre on OpenFreeMap Positron: OpenStreetMap data, English and Dari place names (chosen by the owner on 28 Oct after a side-by-side with OSM standard tiles, which carry the same data). Fitted to the placed sites, clear of the key | no placed site: Afghanistan, and the key says so |
| M2 | What do the colours mean? | Markdown (static) | — | the status definitions below; "Colours show evidence status, not performance"; OSM attribution | — | | Towers | one per site with an accepted location: a pin holding a lattice tower, in the site's baseline status colour (below); a site that is not an active pilot site faded; uniform size. The site's identity is `site_uid`, since two sites can share a `site_code` (an alias cut to 20 characters) | sites without an accepted location are not drawn |
| M3 | Which sites match, and how do I open one? | Table; search box; server pagination | v_site | code, name, province, pilot status, baseline status, L/h (+ method), L/day, latest record, location status, `detail_link` | marker click narrows it; "not available" shown as text | | Popup | opens 120 ms after hover; stays while the pointer is on the tower or the popup; closes 250 ms after it leaves both. A click, tap or keyboard focus pins it; Esc or a click on the map closes it. It springs out of the tower (a fade only with reduced motion) and takes the side with room; when neither side has room for all of it, the side with more room, where its figures scroll while the header, the status with any validity note, and **Site details** stay in view. Content: code, name; status as `baseline_status_text` ("not approved" for Eligible and Provisional); any `baseline_validity_note` right beside the status and the L/h; L/h with method ("not available" when NULL); L/day, "whole record, not per running hour"; latest record; pilot status by name (active, reference, candidate, withdrawn, or not in the pilot); other sites whose towers cover this one (the same or nearly the same position), each a button that switches the popup to it; **Site details** | an approved estimate whose inputs changed is never shown without its validity note |
| M4 | How many sites lack an accepted location? | Big Number Total, COUNT(*) with saved filter `location_status <> 'accepted'` | v_site | count; follows the dashboard filters | 0 is shown as 0 | | Site details | a link (`target="_top"`) to this dashboard with the site preset in the Site filter, on the Site details tab: the `?native_filters=` value of §5 plus `#TAB-site`. The iframe's address names the dashboard, the filter and the tab, and `build_bundle.py` checks them | no link when that address is missing or malformed: the view accepts only a dashboard path and filter and tab ids of a fixed shape |
| M5 | Which sites are they? | Table; search box | v_site, same saved filter | code, name, province, `location_status` (awaiting acceptance · missing), baseline status, `detail_link` | — | | Search | "Find a site: code or name", top left. Matches ignore case: code prefixes first, then codes containing the text, then names containing it; at most 8, each with its status dot and status. Arrow keys and Enter, or the pointer, choose one; the map flies to it at zoom 14 or closer (past the clusters) and pins its popup. Esc clears the field | sites without an accepted location are not searched (owner's choice, 28 Oct): the key counts them and the Site filter finds them; "No site matches" when nothing does |
| Operator's logo | ATOMA's logo in its own colours, bottom right above the map's credits, on frosted glass (*owner, 29 Oct*). The dashboard passes it in the iframe's address (`logo=/static/assets/branding/atoma_logo.svg`, `logo_alt=ATOMA`; `build_bundle.py` checks the file exists); the view takes only an image from that folder on its own origin, so it carries no operator's branding | no `logo` in the address: no logo |
| Key | the four statuses; "Faded: not an active pilot site"; "Colours show evidence status, not performance"; the number of sites without an accepted location, with "Choose them in Site details" | — |
| Keyboard, screen readers | a hidden list of the sites with the same links; focusing one opens its popup | — |
**Filters** (all on `v_site`, scoped to M1, M3, M4 and M5): **Baseline status and tower colours.** Colours are neutral categorical hues, with no red or green
- Site search: Select on `site_label`, multi, searchable. The label holds code, name and uid, so
code or name both match.
- Zone, Province: Select.
- Pilot status: default `active`.
- Baseline status: Select on `baseline_status_label`.
The cross-filter from M1 is scoped **to M3 only**. M4 and M5 hold the sites without coordinates and
must not be emptied by it.
**Baseline status and marker colours.** Colours are neutral categorical hues, with no red or green
and no reference to L/h: and no reference to L/h:
| Status | Definition (`fm.v_site_baseline_status`) | Colour | | Status | Definition (`fm.v_site_baseline_status`) | Colour |
@@ -90,24 +131,26 @@ and no reference to L/h:
| Provisional | no approval; a proposed version below BP-1, **or** usable intervals with no version yet (no estimate shown, method "no baseline version proposed") | amber #c98500 | | Provisional | no approval; a proposed version below BP-1, **or** usable intervals with no version yet (no estimate shown, method "no baseline version proposed") | amber #c98500 |
| Insufficient evidence | no usable interval. **Every pilot site while no runtime evidence exists** | slate #64748B | | Insufficient evidence | no usable interval. **Every pilot site while no runtime evidence exists** | slate #64748B |
**Nearby sites.** **Nearby sites.** Towers that would overlap cluster into a teal circle with their count (radius
- Sites a few kilometres apart separate at zoom ≥ 11 (≈ 60 px per 2–4 km at zoom 12) and coincide 36 px, up to zoom 13); a click on it zooms in until they separate. The pilot's sites, a few
at country zoom (≈ 2 km per pixel at zoom 6). The pilot's own distances are in the design doc. kilometres apart, separate at the zoom the map opens with. The towers are one symbol layer, so
- Handling: thousands of sites stay fast.
- autozoom to the filtered sites (the default "active" filter opens on the pilot area);
- small uniform markers (min = max radius, about 7 px) with an outline;
- a click near overlapping markers selects every site in the click box, and M3 lists them;
- no clustering.
- With hundreds of sites, a `deck_multi` density layer (screengrid) for low zoom is a later option.
## 4. Site Detail (one dashboard, any site) **Sites without an accepted location** are not plotted. The key counts them, and the Site filter
in Site details lists every site, placed or not.
## 4. Site details: the four tabs of `atoma-site` (any site)
*(PD-13)* What follows about D0 (the site header row) and the Fleet tab is history: the app shows
the site's header, and its Map lens replaced the Fleet tab.
**Filters** **Filters**
- **Site** (`NATIVE_FILTER-site`): - **Site** (`NATIVE_FILTER-site`):
- Select on `v_site.site_label`, single value, **required**, searchable, no default; - Select on `v_site.site_label`, single value, **required**, searchable,
**first value by default** with `requiredFirst` (the §4 fallback, adopted in P2);
- scope: every chart on every tab; - scope: every chart on every tab;
- every detail dataset carries `site_label` from `v_site`, so this one filter drives all of - every detail dataset carries `site_label` from `v_site`, so this one filter drives all of
them *(local test)*. them (confirmed in P2: every chart request carries the selected site).
- **Viewing period** (`NATIVE_FILTER-period`): - **Viewing period** (`NATIVE_FILTER-period`):
- Time range, default no filter; - Time range, default no filter;
- scope: the time-series charts on Fuel & Deliveries and Runtime & Electricity only; - scope: the time-series charts on Fuel & Deliveries and Runtime & Electricity only;
@@ -115,24 +158,31 @@ and no reference to L/h:
baseline's fixed window. baseline's fixed window.
**Exactly one site, and never network totals:** **Exactly one site, and never network totals:**
1. A required, single-value filter *(local test: with the value cleared, no chart queries 1. A required, single-value filter. *P2: "required" alone does not stop the charts: with no
unfiltered data)*. value they queried every site.*
2. The header (D0) and KPI (S1) are Handlebars templates that render a site only when exactly one 2. Therefore "Select first filter value by default", with `requiredFirst`, as the filter editor
row arrives. Otherwise they show "Select one site". writes it: the charts wait for the filter, the first site is applied without "Apply filters",
and "Clear all" stays disabled. Without `requiredFirst` the first value waits behind "Apply
filters" while the charts have already queried every site.
3. No Big Number or aggregate tile for site figures. They are computed per site in `v_site`, 3. No Big Number or aggregate tile for site figures. They are computed per site in `v_site`,
never aggregated by Superset. never aggregated by Superset. *(29 Oct, PD-12: the app's Overview, §10, adds up recorded
4. Fallback, if (1) fails its test: "Select first filter value by default", so the Site filter amounts across sites; per-site rates are still never averaged.)*
is never empty. 4. The Handlebars "Select one site" guard is gone with Handlebars (§1); with (2) the filter is
never empty.
``` ```
[D0 Site header: code + name (large) · province · pilot status · baseline status · "← Sites map"] [D0 Site: one-row table, code · name · province · pilot status · baseline status · location] (28 Oct: inside Site details, no link)
Summary [S1 L/h KPI card w6][S2 Supporting measures w6] every tab [Reading notes (Markdown) w12] (P2)
Summary [S1 Fuel per running hour: one-row table w12] (P2)
[S2 Supporting measures w12]
[S3 Evidence coverage timeline (Gantt) w12] [S3 Evidence coverage timeline (Gantt) w12]
Fuel & Deliveries [F1 Deliveries w6][F2 Recorded stock w6] Fuel & Deliveries [F1 Deliveries w6][F2 Recorded stock w6]
[F5 Deliveries table w12] (P2)
[F3 Fuel per calendar day by interval w12] [F3 Fuel per calendar day by interval w12]
[F4 Interval table w12] [F4 Interval table w12]
Runtime & Electricity[R1 Runtime per day by meter interval w6][R2 Hour-meter evidence w6] Runtime & Electricity[R1 Runtime per day by meter interval w6][R2 Hour-meter evidence w6]
[R3 Grid energy, kWh w6][R4 Electricity cost, AFN w6] [R1T Runtime pairs and their validity w12] (P2)
[R3 Grid energy, kWh w6][R4 Electricity cost, AFN: bar over table w6]
Baseline & Evidence [B1 Baseline versions w6][B2 Inputs of each version w6] Baseline & Evidence [B1 Baseline versions w6][B2 Inputs of each version w6]
[B3 All intervals: eligibility and exclusions w12] [B3 All intervals: eligibility and exclusions w12]
[B4 Interval L/h against baseline w12] [B4 Interval L/h against baseline w12]
@@ -144,17 +194,17 @@ Baseline & Evidence [B1 Baseline versions w6][B2 Inputs of each version w6]
| # | Question it answers | Type | Dataset | Dims / measures (units) | Grain | When data are missing or insufficient | | # | Question it answers | Type | Dataset | Dims / measures (units) | Grain | When data are missing or insufficient |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| D0 | Which site am I looking at? | Handlebars | v_site | code and name (large), province, pilot status, baseline status, location status, link "← Sites map" | site | "Select one site" unless exactly one row | | D0 | Which site am I looking at? | Table, one row *(P2)* | v_site | code, name, province, pilot status, baseline status, location status *(28 Oct: the link "← Sites map" is gone; the Fleet tab is one click away)* | site | the Site filter is never empty (§4) |
| S1 | What is the site's L/h, by which method, over which window, how strong? | Handlebars | v_site | `lph_estimate`, `lph_method`, `baseline_status_label`, `baseline_validity_note`, window, decision; L/day whole record | site | Keeps its position. **In v1 every site shows "Insufficient runtime evidence: no hour-meter readings"**, plus L/day labelled "per calendar day, not per running hour". "Eligible" and "Provisional" carry "not approved" | | S1 | What is the site's L/h, by which method, over which window, how strong? | Table, one row *(P2)* | v_site | `lph_estimate`, `lph_method`, `baseline_status_label`, `baseline_validity_note`, window, decision; L/day whole record | site | Keeps its position. **In v1 every site shows "Insufficient runtime evidence: no hour-meter readings"**, plus L/day labelled "per calendar day, not per running hour". "Eligible" and "Provisional" carry "not approved" |
| S2 | What supports it? | Table | v_kpi_measures | measure, value, unit, `value_kind` (recorded · calculated · assumed · not established), window. Two L/day rows: the whole record (moves with new records) and the L/day reference version (fixed window, its status and any input drift in the note) | site × measure | Rows stay and show "not established" | | S2 | What supports it? | Table | v_kpi_measures | measure, value, unit, `value_kind` (recorded · calculated · assumed · not established), window. Two L/day rows: the whole record (moves with new records) and the L/day reference version (fixed window, its status and any input drift in the note) | site × measure | Rows stay and show "not established" |
| S3 | Which periods are covered by which evidence, and where are the gaps? | Gantt | v_coverage | lane; start/end; colour = status | span | Empty lanes are visible; nothing is interpolated | | S3 | Which periods are covered by which evidence, and where are the gaps? | Gantt | coverage_detail *(P2: virtual dataset over v_coverage)* | lane name without the view's sort number; start/end; colour = status; tooltip: litres delivered in a stock interval, or a bill's AFN amount | span | Nothing is interpolated. *(P2)* The view emits a lane only where spans exist, so the tab's notes name the four lanes and say that a missing lane means no span could be formed: no evidence of that kind, or too few readings (F2, R2 show single readings) |
| F1 | When and how much was delivered? | Bar (time axis, **day** grain) | v_delivery (one row per delivery event) | `delivery_date`; **SUM(`delivered_l`)**, titled "Litres delivered per day (all deliveries that day)"; tooltip COUNT(deliveries) | day | Bars only on delivery dates; subtitle "deliveries are not consumption"; F4 lists the individual deliveries | | F1 | When and how much was delivered? | Bar (time axis, **day** grain) | v_delivery (one row per delivery event) | `delivery_date`; **SUM(`delivered_l`)**, titled "Litres delivered per day (all deliveries that day)" | day | Bars only on delivery dates; "deliveries are not consumption" in the tab's notes. *(P2)* The count and the individual deliveries are in **F5**, a table on v_delivery (date, visit of the day, event, litres, price, sources, review), which also lists a delivery of unknown quantity; its search box shows the number of records |
| F2 | What stock was recorded? | Scatter (no lines) | v_stock_reading | `observed_on`; litres; series = reading type × `value_kind` | observation | Points only; nothing between readings | | F2 | What stock was recorded? | Scatter (no lines) | v_stock_reading | `observed_on`; litres; series = reading type × `value_kind`, split by visit when a day has several *(P2)* | observation | Points only; nothing between readings |
| F3 | How much fuel was used per calendar day in each observed interval? | Bar (categorical x, ordered by start) | v_interval | `interval_label`; MAX(`fuel_l_per_day`) (L/d); colour = `stock_status` | stock interval | Excluded intervals greyed, with the reason in the tooltip; no monthly spreading | | F3 | How much fuel was used per calendar day in each observed interval? | Bar (categorical x, ordered by start) | v_interval | `interval_label`; MAX(`fuel_l_per_day`) (L/d); colour = `stock_status` | stock interval | Intervals that are not `ok` greyed (*P2*: fixed grey per status in Site Detail's `label_colors`); the status is the series name, so legend and tooltip give the reason; no monthly spreading |
| F4 | Interval detail | Table | v_interval | dates, opening, deliveries, closing, used, L/d, status | interval | Shows the reasons | | F4 | Interval detail | Table | v_interval | dates, opening, deliveries, closing, used, L/d, status | interval | Shows the reasons |
| R1 | How many hours did each generator run per day? | Bar (categorical) | v_runtime | interval × meter; MAX(h/day). h/day is NULL for an invalid pair (`pair_valid` false: two epochs, a decreasing timer or over 24 h/day); such pairs show no bar and are listed with their flags in the tooltip | meter interval | **v1: empty**, with "No hour-meter readings" in the description | | R1 | How many hours did each generator run per day? | Bar (categorical) | v_runtime | interval × meter; MAX(h/day). h/day is NULL for an invalid pair (`pair_valid` false: two epochs, a decreasing timer or over 24 h/day); such pairs show no bar and are listed with their flags in **R1T**, a pair table with `pair_valid`, `same_epoch`, `monotonic`, `over_24h_per_day` *(P2: the bar tooltip cannot show them)* | meter interval | **v1: empty**, with "No hour-meter readings" in the tab's notes |
| R2 | What runtime evidence exists? | Table | v_hour_meter_evidence | meter, mapped set, date, hours, state, epoch | reading | **v1: no rows**, "No hour-meter readings" | | R2 | What runtime evidence exists? | Table | v_hour_meter_evidence | meter, mapped set, date, hours, state, epoch | reading | **v1: no rows**, "No hour-meter readings" |
| R3 | How much grid energy did the site receive? | Handlebars → bar once kWh exist | v_kpi_measures (kWh row) | kWh per bill month | month | "No kWh records; bills are cost only and are not converted to fuel or hours" | | R3 | How much grid energy did the site receive? | Table, the kWh row *(P2)*; a bar once kWh exist | v_kpi_measures (kWh row) | kWh per bill month | month | "No kWh records; bills are cost only and are not converted to fuel or hours" |
| R4 | What did grid electricity cost? | Bar (month) + table | v_electricity_cost | bill month; amount (AFN, inferred) | month | Own chart, never on a fuel axis. A disputed bill is listed without amount or kWh | | R4 | What did grid electricity cost? | Bar (month) + table | v_electricity_cost | bill month; amount (AFN, inferred) | month | Own chart, never on a fuel axis. A disputed bill is listed without amount or kWh |
| B1 | Which baseline versions exist, and who approved them? | Table | v_baseline_version | code, status, value, window, grade, decision | version | "No baseline defined" | | B1 | Which baseline versions exist, and who approved them? | Table | v_baseline_version | code, status, value, window, grade, decision | version | "No baseline defined" |
| B2 | What went into each version? | Table | v_baseline_input | interval, included, reason, fuel, runtime, drift | version × interval | empty until a version exists | | B2 | What went into each version? | Table | v_baseline_input | interval, included, reason, fuel, runtime, drift | version × interval | empty until a version exists |
@@ -164,24 +214,31 @@ Baseline & Evidence [B1 Baseline versions w6][B2 Inputs of each version w6]
| B6 | What is missing or inconsistent? | Table | v_data_quality | check, status, detail | check | Lists the gaps: no runtime, placeholder rows, no kWh, no transfer records, location awaiting acceptance | | B6 | What is missing or inconsistent? | Table | v_data_quality | check, status, detail | check | Lists the gaps: no runtime, placeholder rows, no kWh, no transfer records, location awaiting acceptance |
| B7 | Which documents do the figures rest on? | Table | v_evidence | title, kind, issuer, date, used for | document | — | | B7 | Which documents do the figures rest on? | Table | v_evidence | title, kind, issuer, date, used for | document | — |
| B8 | What did the operator workbook calculate, and is it usable? | Table | v_imported_calculation | label, formula, value, our reading, use, reason, `claim_status` | cell | Shows "excluded" with the reason for "actual L/h" and "CP L/h". A disputed transcription shows its status, without formula or value | | B8 | What did the operator workbook calculate, and is it usable? | Table | v_imported_calculation | label, formula, value, our reading, use, reason, `claim_status` | cell | Shows "excluded" with the reason for "actual L/h" and "CP L/h". A disputed transcription shows its status, without formula or value |
| B9 | Does stock depletion match engine-measured fuel? | Table | v_reconciliation | stock L, engine L, `covered_h` of `interval_h`, difference L and %. The window runs from the opening visit's departure to the closing visit's arrival. The difference is shown only for an `ok` stock interval with both visit times recorded, in which every generator is placed at the site throughout and covered for the whole window | interval × generator | "No flow-meter data yet"; partial coverage, unknown visit times or a mid-interval placement show the hours covered and no difference | | B9 | Does stock depletion match engine-measured fuel? | Table | v_reconciliation | engine L of this generator, `covered_h` of `interval_h`; site-wide: stock L, engine L of all generators, `all_generators_metered`, difference L and % *(P2: both engine totals shown, so the difference reconciles)*. The window runs from the opening visit's departure to the closing visit's arrival. The difference is shown only for an `ok` stock interval with both visit times recorded, in which every generator is placed at the site throughout and covered for the whole window | interval × generator | "No flow-meter data yet"; partial coverage, unknown visit times or a mid-interval placement show the hours covered and no difference |
| B10 | What does the equipment specification say? | Table | v_spec_reference | attribute, value, unit, basis, applicability, `claim_status` | spec | Titled "Manufacturer and nameplate data, not site performance". A disputed specification shows its status, without value | | B10 | What does the equipment specification say? | Table | v_spec_reference | attribute, value, unit, basis, applicability, `claim_status` | spec | Titled "Manufacturer and nameplate data, not site performance". A disputed specification shows its status, without value |
| B11 | What would L/h be under the stated runtime? | Table | v_modelled_lph | assumption code, status, basis; h/day with low–high; window, `matched_days`; L/day; modelled L/h with low–high; `value_kind` | site × assumption | Titled "Modelled from an assumed runtime, not measured". "No runtime assumption recorded" | | B11 | What would L/h be under the stated runtime? | Table | v_modelled_lph | assumption code, status, basis; h/day with low–high; window, `matched_days`; L/day; modelled L/h with low–high; `value_kind` | site × assumption | Titled "Modelled from an assumed runtime, not measured". "No runtime assumption recorded" |
Modelled L/h (B11) is in v1 (Q9, decided 24 Oct 2026). It pools only the usable intervals inside Modelled L/h (B11) is in v1 (Q9, decided 24 Oct 2026). It pools only the usable intervals inside
the runtime assumption's dates, and shows that window and its days. It never reaches S1, the map or the runtime assumption's dates, and shows that window and its days. It never reaches S1 or the
the M3 Sites list. fleet map.
## 5. Datasets and filter mapping ## 5. Datasets and filter mapping
Physical datasets, one per `public.v_*` view on Trino `fuel.public`. Every dataset has the same Physical datasets, one per `public.v_*` view in fuel-db's `public` schema, plus one virtual dataset
four site columns from `v_site`: `site_uid`, `site_code`, `site_name`, `site_label`. *(P2)*. `coverage_detail` adds two columns to `v_coverage`:
- `lane_name`, the lane without its sort number, which read as a count;
- `amount`, a stock interval's delivered litres (from its opening visit up to its closing visit,
so the opening refill is included) or a bill's AFN amount.
Both use only `v_coverage`, `v_delivery` and `v_electricity_cost`. Every dataset has the same
five columns from `v_site`: `site_uid`, `site_code`, `site_name`, `site_label`, `operator_uid` (the customer; the row filter uses it, PD-16).
| Dataset | Grain | Used by | Native filters that apply | | Dataset | Grain | Used by | Native filters that apply |
|---|---|---|---| |---|---|---|---|
| v_site | site | M1–M5, D0, S1 | landing: all five filters, plus the M1 cross-filter (on M3); detail: Site | | v_site | site | D0, S1; the fleet-map view through the read service *(28 Oct)* | Site |
| v_kpi_measures | site × measure | S2, R3 | Site | | v_kpi_measures | site × measure | S2, R3 | Site |
| v_coverage | site × lane × span | S3 | Site | | v_coverage | site × lane × span | — (S3 reads `coverage_detail`) | Site |
| coverage_detail *(P2)* | site × lane × span | S3 | Site |
| v_delivery | delivery | F1 | Site, Viewing period | | v_delivery | delivery | F1 | Site, Viewing period |
| v_stock_reading | observation | F2 | Site, Viewing period | | v_stock_reading | observation | F2 | Site, Viewing period |
| v_interval | stock interval | F3, F4, B3, B4 | Site; Viewing period on F3 and F4 only | | v_interval | stock interval | F3, F4, B3, B4 | Site; Viewing period on F3 and F4 only |
@@ -198,79 +255,341 @@ is grouped or filtered by `metric` and `population_key` (both in `v_interval`),
across methods or generator sets. The site's L/h figure itself comes only from a baseline version across methods or generator sets. The site's L/h figure itself comes only from a baseline version
(`v_site`), never from this metric. There is no AVG metric on ratio columns. (`v_site`), never from this metric. There is no AVG metric on ratio columns.
**Link column.** `detail_link` is a calculated column on the `v_site` dataset, defined in the **Site details link** *(28 Oct; history since PD-13)*. The fleet-map view built it
bundle in Trino SQL. The native filter id stays inside Superset's own configuration: (petrolyst-platform `views/src/shared/deepLink.ts`, retired) from its iframe address: the
dashboard path, the Site filter's id and the tab. The Petrolyst app uses the same rison shape for
the Site filter's preset (`app/src/app/core/site-filter.ts`). The rison shape is the one the sites
list used until 28 Oct, confirmed in P2, and the view's tests compared it with that list's
output:
```sql ```
'<a href="/superset/dashboard/atoma-site-detail/?native_filters=' /superset/dashboard/atoma/?native_filters=<url-encoded:>
|| url_encode('(NATIVE_FILTER-site:(id:NATIVE_FILTER-site,' (NATIVE_FILTER-site:(id:NATIVE_FILTER-site,extraFormData:(filters:!((col:site_label,op:IN,val:!(q)))),
|| 'extraFormData:(filters:!((col:site_label,op:IN,val:!(' || q || ')))),' filterState:(value:!(q)),ownState:()))
|| 'filterState:(value:!(' || q || ')),ownState:()))') #TAB-site
|| '">Open site detail</a>' -- q = the site label in rison quotes: 'label', with ! written !! and ' written !'
-- q = '''' || replace(replace(site_label, '!', '!!'), '''', '!''') || '''' -- rison string quoting
``` ```
- The exact rison state shape is a *(local test)*.
- Fallback: the link opens Site Detail with the Site filter expanded (`expand_filters=1`) and the
site's label shown beside the link, to pick by search.
## 6. Build as code, scale, access ## 6. Build as code, scale, access
**Build** **Build**
- `petrolyst-insights/dashboards/atoma/build_bundle.py` writes one import bundle containing both - `build_bundle.py --database-uuid <uuid>` writes one import bundle containing the dashboard,
dashboards, every dataset and every chart: every dataset and every chart:
- stable UUIDs (uuid5), so a re-import updates instead of duplicating; - stable UUIDs (uuid5), so a re-import updates instead of duplicating; the zip is
- fixed native-filter ids (`NATIVE_FILTER-site`, `-period`, `-search`, …); byte-identical for the same inputs;
- fixed native-filter ids (`NATIVE_FILTER-site`, `NATIVE_FILTER-period`) and tab ids
(`TAB-fleet`, `TAB-site`, `TAB-summary`, …);
- `label_colors` for the four statuses; - `label_colors` for the four statuses;
- the existing "Fuel (Trino)" database UUID, read from an export first. - every view column, from `columns.tsv` (a snapshot of PostgreSQL's catalog);
- Import: `superset import-dashboards`. - the existing "Fuel" database UUID, which differs per instance:
`import_bundle.py --show-database` prints it.
- Before writing, it checks that every column a chart or filter uses exists in its dataset,
that every chart in a Site filter's scope has `site_label`, that the fleet map's iframe
address names this dashboard, its Site filter and the Site details tab *(28 Oct)*, and that
no row is wider than 12.
- Import: `import_bundle.py <zip> --user <username>`, inside the Superset container (*P2*: not
`superset import-dashboards`, which leaves existing datasets and charts unchanged on a
re-import). It runs the dataset, chart and dashboard importers with overwrite on, never creates
or changes the database, and reports columns that differ between a dataset and its view.
- A re-import replaces edits made in the Superset UI to these objects.
**Adding a site** **Adding a site**
- Import its `site.yaml` and its records. Nothing in either dashboard names a site. - Import its `site.yaml` and its records. Nothing in the dashboard or the fleet-map view names a
- New sites appear in M1 (once their location is accepted) or in M5, in M3, and in the Site filter. site.
- A new site appears in the Site filter, and on the map once its location is accepted; until
then the map's key counts it.
**Hundreds of sites** **Hundreds of sites**
- `v_site` has one row per site, and the scatter layer handles thousands of points. - `v_site` has one row per site; the fleet map draws its towers as one symbol layer and
- The Select filters load the distinct labels; M3 and M5 paginate on the server. clusters overlapping ones, so thousands of sites stay fast.
- The Site filter loads the distinct labels; with thousands of sites, check its value limit
(open point 6 of the design decisions).
**Superset configuration change** (petrolyst-insights; image rebuild in P3): **Superset configuration changes** (in `superset_config.py`; image rebuild in P3):
`DECKGL_BASE_MAP = [["tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap"]]`. - `DECKGL_BASE_MAP = [["tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap"]]`
The CSP needs no change *(local test: no CSP violation in the browser console)*. (no deck.gl chart since 28 Oct; kept for charts made by hand);
- *(28 Oct)* `HTML_SANITIZATION_SCHEMA_EXTENSIONS` lets an `iframe` (`src`, `title`, `loading`,
`style`) through a Markdown block. The fleet-map view must be served on the same origin under
`/views` (petrolyst-infra routing, not part of this step).
**Access, internal first.** Both dashboards for Admin and Alpha only. No Gamma role until the The CSP needs no change. P2: the tiles load and the map causes no CSP violation. One violation
appears on every Superset page, including the stock dashboard list: a capability probe
(`Function("")`) in the 6.1 frontend bundle, caught by the bundle itself.
**Access, internal first.** The dashboard for Admin and Alpha only; the read service behind the
same sign-in *(28 Oct: not built yet; locally it has none)*. No Gamma role until the
ATOMA-facing edit. ATOMA-facing edit.
**ATOMA's readers** *(owner, 29 Oct; retired the same day, PD-13: readers use the Petrolyst
app; kept as history)*. The Superset role **"ATOMA Reader"** (Keycloak client role
`atoma-reader`) reads the ATOMA dashboard and nothing else.
- **The role** (`petrolyst-insights/roles/atoma_reader.py`): exactly 12 permissions, set by
`superset-init` whenever the image changes, and reset by hand with the command in the README.
- Dashboard and Chart `can_read`;
- Superset `can_dashboard`, `can_dashboard_permalink`, `can_log`;
- Api `can_time_range`;
- the filter state (`can_read` and `can_write`), permalinks (`can_read`), the current user,
the CSRF token and the menu.
It has no menus, no write rights, no Explore, CSV, sharing, drill or query view. The bundle
import attaches the role to the dashboard (DASHBOARD_RBAC).
- **Rules a role cannot express** (`petrolyst_reader.py`, in both security managers):
- readers own nothing, and reach a dashboard only through its roles (published, role match);
- the dashboard list shows them only those;
- their chart-data responses carry no SQL (`patches/fix_redact_query_for_readers.py`;
Superset itself drops it only for embedded guests).
- **What they see:**
- every page they open leads to the ATOMA dashboard;
- a stylesheet loaded only for them (`assets/branding/reader.css`) hides the dashboard's
title row, the theme switcher, and Info and About in Settings, leaving Logout;
- the same stylesheet hides the filter column on the Fleet tab, and while the dashboard
loads. It sizes the map to the window: the full width at once (Superset measures widths
again only about 0.3 s after loading), and down to 32 px above the window's bottom edge
instead of the layout's 800 px, at least 560 px. `build_bundle.py` checks that it names the
Fleet tab and that the map spans all 12 columns;
- the same stylesheet turns the top-level tabs into a lens switch, centred in the navbar, with
an icon each; the map's block then grows by the tab row's height. A scroll-linked animation
moves the switch up with the navbar, which scrolls away. Without support for scroll-linked
animations, or narrower than 900 px, the tab row stays in the page. From 1100 px the switch
shows a third lens, AI, as a teaser: greyed, tagged PRO in green, drawn by the tab list
(not a tab, not clickable; the layout keeps Map and Insights). Narrower, it would cover the
logo. `build_bundle.py` checks that every layout id the stylesheet names exists;
- the stylesheet's address carries a hash of its content
(`petrolyst_reader.register`): Superset lets browsers keep static files for a year, so a
changed file would otherwise not reach readers who loaded the old one;
- Superset already hides the menus, "+" and editing from a role without them.
- **One refused request remains, harmless:** Superset stores a dashboard's label colours when
they changed (`PUT /api/v1/dashboard/<id>/colors`). A reader may not, so it is refused until
someone with edit rights opens the dashboard; the colours show either way.
- A reader is someone whose only roles are "ATOMA Reader" (and Public). Anyone with Admin, Alpha
or Gamma as well sees everything as before.
## 7. Acceptance checks ## 7. Acceptance checks
*(PD-13)* Written for the retired ATOMA dashboard. Where they name D0 or the fleet map, the app's
site header and Map lens take their place (petrolyst-platform's browser tests).
Run in P2 against a local Superset, and repeated as a smoke test in P3. Run in P2 against a local Superset, and repeated as a smoke test in P3.
1. **New site, no new objects.** Count dashboards, charts and datasets in the metadata database. 1. **New site, no new objects.** Count dashboards, charts and datasets in the metadata database.
Then import a synthetic fourth site with records and an accepted location, and a fifth with no Then import a synthetic fourth site with records and an accepted location, and a fifth with no
location. The counts are unchanged; site 4 appears in M1, M3 and the Site filter; site 5 location. The counts are unchanged; site 4 appears on the fleet map and in the Site filter;
appears in M5, and M4 goes up by 1. site 5 appears in the Site filter, and the map's count of sites without an accepted location
goes up by 1 *(28 Oct)*.
2. **Site switching.** In Site Detail, select pilot site A, then site B. D0 shows the new code and 2. **Site switching.** In Site Detail, select pilot site A, then site B. D0 shows the new code and
name; every chart's query (View query, or chart-data API) carries `site_label = …<site B>…`; no name; every chart's query (View query, or chart-data API) carries `site_label = …<site B>…`; no
chart keeps site A's rows. chart keeps site A's rows.
3. **Clearing the selection.** With the Site filter cleared, no chart shows multi-site data; D0 and 3. **Clearing the selection.** With the Site filter cleared, no chart shows multi-site data; D0 and
S1 show "Select one site". If not, switch to the §4 fallback and re-test. S1 show "Select one site". If not, switch to the §4 fallback and re-test.
4. **Unavailable L/h.** For each pilot site: 4. **Unavailable L/h.** For each pilot site:
- the tooltip and M3 say "not available" with the method "no runtime evidence"; - the fleet map's popup says "not available" with the method "no runtime evidence" *(28 Oct)*;
- S1 shows the insufficient-evidence state plus L/day; - S1 shows the insufficient-evidence state plus L/day;
- B3 shows every interval excluded, with its reason; - B3 shows every interval excluded, with its reason;
- B11 shows a modelled range labelled "not measured", and that figure appears nowhere else. - B11 shows a modelled range labelled "not measured", and that figure appears nowhere else.
5. **Missing coordinates.** A site without an accepted location is not plotted, is listed in M5 5. **Missing coordinates.** A site without an accepted location is not plotted, is counted in the
and counted in M4, and its link opens its detail. map's key, and can be chosen in the Site filter *(28 Oct)*.
6. **Filter propagation.** 6. **Filter propagation.**
- On the landing page, each filter changes M1, M3, M4 and M5, and the default "active" shows - *(28 Oct)* Both filters are out of scope on the Fleet tab; the fleet map shows every placed
exactly the active pilot sites. site, sites that are not active pilot sites faded.
- A marker click filters M3 only. - In Site details, the Viewing period changes F1–F5, R1, R1T, R2, R4 and R4T, but not S1 or
- In Site Detail, the Viewing period changes F1–F3 and R1, R2, R4, but not S1 or B1–B4. B1–B4.
7. **Detail links.** For each active pilot site, "Open site detail" in M3 opens Site Detail with 7. **Detail links** *(28 Oct)*. For each placed site, "Site details" in its popup opens the Site
exactly that site selected, and D0 matches. Same from M5. details tab with exactly that site selected and Summary open; D0 matches, and every chart's
request carries that site.
8. **Colours.** 8. **Colours.**
- Fixtures in each status show the four colours. - Fixtures in each status show the four colours.
- "Eligible" never renders as "Approved" unless the fixture has an approved version with a - "Eligible" never renders as "Approved" unless the fixture has an approved version with a
decision reference. decision reference.
- Marker size is identical across sites. - Marker size is identical across sites.
9. **Basemap.** OSM tiles load, there are no CSP violations in the console, and the attribution - *(28 Oct)* An approved site whose inputs changed shows its validity note beside the status
is visible. and the L/h in the popup.
9. **Basemap.** The fleet map's tiles load, the attribution is visible, and neither the view nor
the dashboard adds a CSP violation (Superset's own probe, §6, stays) *(28 Oct)*.
## 8. P2 results (local, 24 Oct 2026)
Local Superset 6.1.0 image built from this repository, fuel-db with migrations
0001–0003 and the corpus data loaded; browser checks in headless Chrome.
| Check | Result |
|---|---|
| 1 New site | pass: with a synthetic site with an accepted location and one without, the object counts stay unchanged (2 dashboards, 28 charts before R1T was added, 18 datasets); the first is in M1, M3 and the Site filter, the second in M5, and M4 goes from 0 to 1. Fixtures removed afterwards |
| 2 Site switching | pass: for each active pilot site, every chart request on the four tabs carries that site's `site_label`, all HTTP 200 (25 per site; F5, added later, re-checked for one site) |
| 3 Clearing | pass with the fallback (§4): "required" alone let the charts query every site |
| 4 Unavailable L/h | pass: tooltip and M3 "not available", method "Not available: no runtime evidence"; S1 the same plus L/day; B3 every interval excluded with its reason; B11 the modelled range, labelled "not measured", nowhere else |
| 5 Missing coordinates | pass (synthetic site, check 1): not plotted, in M4 and M5, its link opens its detail |
| 6 Filter propagation | pass: the default "active" reaches M1, M3, M4, M5; a marker click re-queries M3 only, with a latitude/longitude box; the Viewing period reaches F1–F5, R1, R1T, R2, R4 and the bill table, and no Summary or Baseline & Evidence chart |
| 7 Detail links | pass: from M3 for each active pilot site, and from M5 |
| 8 Colours | pass for the mapping: a throwaway fixture with one point per status shows the four colours and equal marker sizes. Not tested by fixture: that the database never labels an unapproved version "Approved baseline" |
| 9 Basemap | pass: OSM tiles load, attribution visible, no CSP violation from the map |
Changes forced by the tests and by two Codex inspection rounds of 24 Oct (F2 visits, R1T, B9
totals, coverage lanes; then F5, grey stock statuses, lane wording), all marked *(P2)* above.
The second round's fixes were not inspected again. The grey colour for a non-ok stock interval
is configured but not shown by the current data, which has no such interval.
- Handlebars charts (D0, S1, R3) became one-row tables.
- The Site filter uses "first value by default" with `requiredFirst`.
- Reading notes moved into a Markdown block per tab.
- The map tooltip uses short tooltip-only columns.
- The markers have no outline.
The marker palette is unchanged from §3. The dataviz palette check flags slate as reading
grey, which is intended for "insufficient evidence", and amber at 2.99:1 contrast. The label is
always also shown as text, in the legend, the tooltip and M3.
## 9. Fleet map results (local, 28 Oct 2026) *(history)*
Local Superset 6.1.0 rebuilt with the iframe setting, behind a local Traefik that serves Superset
and the petrolyst-platform read service on one origin (as on the server); the fuel-db of P2.
Browser checks with Playwright in Chrome (`petrolyst-platform/views/e2e/`).
**Spike, before building**
- A Markdown iframe survives the sanitiser with the setting of §6.
- A `target="_top"` link inside it navigates the whole dashboard page.
- `?native_filters=…#TAB-…` selects the site and opens that tab.
- Basemaps compared at zoom 11, 14 and 16 over the pilot area: OpenFreeMap Positron and OSM
standard tiles show the same OpenStreetMap data; the owner chose Positron.
**Bundle:** 1 dashboard, 26 charts, 19 datasets; two builds are byte-identical; the import reports
column drift 0. The two earlier dashboards and the charts M1, M3, M4 and M5 were deleted from the
local Superset through its API. The live Superset never had them (P3 not done).
| Check | Result |
|---|---|
| 2 Site switching | pass: choosing a second site in the Site filter moves every chart request to it |
| 3 Clearing | pass: after "Clear all" every chart request still carries exactly one site |
| 4 Unavailable L/h | pass for the popup ("not available" and the method); S1, B3 and B11 are unchanged by this step and were not re-run |
| 5 Missing coordinates | pass for the count: the map's key shows the 2 sites without an accepted location |
| 7 Detail links | pass: for each of the 3 placed sites, hover, then "Site details", opens the Site details tab with Summary active, D0 shows the site, and every chart request carries exactly its `site_label` |
| 8 Colours | the validity note beside an approved status and its L/h, and "(not approved)" for Provisional, pass as component tests on fixtures. Live, every site is "Insufficient evidence", so only slate was seen; the other three colours were checked by reading the code against §3, not on screen |
| 9 Basemap | pass: tiles load, attribution visible; no CSP violation from the view. Superset's own `script-src eval` probe appears on every Superset page, as in P2 (§6), and nothing else. The test's watcher was checked with a deliberately disallowed image inside the view |
Also passed: the popup opens on hover, stays while the pointer moves onto it, closes after the
pointer leaves; a click pins it; Esc and a click on the map close it; with reduced motion it only
fades and nothing pulses. The view's minimum size is 480 × 560 px (`catalogue.json`): there, and
also at 480 × 360, every site's popup stays inside the map, and an approved estimate whose inputs
changed is never in view without its warning, checked by scrolling the card. At 480 × 560 every
site's L/h can be read. A single placed site sits clear of the key. Two sites at the same
position can each be opened with the pointer. The states the scratch data lacks (approved with a
changed input, one site, two at one spot) are made in the browser by changing the read service's
answer; the database is unchanged.
**Filters on top and the map search** *(owner, later on 28 Oct)*: the filter row was checked on
screen first: the Fleet tab shows only "More filters", and Site details shows Site and Viewing
period in the row. Browser checks:
- the map takes the page's width;
- the search finds a site by part of its name and by its code, and its popup's "Site details"
opens exactly that site;
- a site inside a cluster is taken out of it;
- site switching and clearing (checks 2 and 3) still pass with the filters in the row.
**ATOMA readers and the operator's logo** *(owner, 29 Oct)*: the plan was reviewed by Codex
(approved after three rounds: deploy inputs, ownership and no-dataset paths, SQL in responses).
Browser checks as the local user `reader`:
- every page leads to the dashboard;
- no title row and no menus; Settings shows only Logout;
- the whole fleet-map suite passes as the reader;
- reading needs nothing the role lacks (only the colours PUT above is refused);
- no chart-data response carries SQL, fresh, cached or as `result_type=query` (an admin's
does);
- CSV and editing answer 403;
- two probe dashboards (Markdown only without roles, and one the reader owns) stay out of
reach: page, API and permalink.
Also checked:
- the role script removes a permission added by hand;
- every new file changes the image tag;
- the patch's `--verify` fails on unpatched Superset and on a tampered line.
Found on the way: under many parallel requests the local proxy returned 502s. gunicorn closes
idle connections after 2 s (`--keep-alive 2`) and Traefik reused them. The local proxy now drops
idle connections after 1 s. **The server has the same pairing** (Traefik in front of gunicorn
with the default keep-alive): open item.
**Final inspection:** two fresh Codex sessions reviewed the build.
- The first found four defects, all fixed and re-checked:
- the popup could leave a small map;
- two sites sharing a `site_code` could open each other's details;
- every non-active pilot status was called "reference";
- a failed database connection gave a 500 instead of the generic 503.
- The second confirmed those fixes and found three more:
- in a short card the validity warning could scroll away from the L/h;
- a single site was centred under the key;
- sites at one position hid each other.
All three are fixed and tested. Those last fixes, and the filter row and the map search, have
not had an independent inspection.
**Filters back on the left, the readers' Fleet tab, the navbar logo, section buttons** *(owner,
29 Oct)*:
checked in the browser as `reader` and `admin` at 1400 × 1000, 1920 × 1080, 1440 × 760,
1280 × 600 and 1024 wide:
- a reader's Fleet tab has no filter column and no resize handle, and the map is as wide as
the page and reaches 32 px above the window's bottom edge (no page scroll), at once rather
than after Superset measures again; at 600 px high it keeps 560 px and the page scrolls;
- Site details shows the column collapsed (32 px, for readers and admins, also after the
map's "Site details" link); expanded, it has the Site filter; switching back hides it again;
- an admin has the column on both tabs;
- the column no longer flashes up while the Fleet tab loads (one frame, about 20 ms, remains:
the grid exists before its tabs do);
- after a switch to Site details its charts are too wide for one or two frames before
Superset measures again; not changed;
- a reader's page links `reader.css?v=<hash>`, an admin's none;
- the navbar shows the wordmark at 24 px for readers, admins and the login page, white in
dark mode;
- Site details' inner tabs are a row of section buttons (`dashboard.css`), lined up with the
content, all four visible and switching, for readers and admins, light and dark; a
vertical list was tried first and dropped: Superset sizes the charts for the full width, so
they were cut off on the right;
- `build_bundle.py` stops when the orientation is not VERTICAL, when reader.css does not name
the Fleet tab, when the map spans fewer than 12 columns, or when `dashboard.css` names an
id that is not in the layout.
- *(lenses)* a reader at 1400 px: the switch centred in the navbar (Map | Insights), the map
735 px high in an 860 px window and 32 px above its bottom edge; scrolled by 30, 61 and
400 px the switch moves up by exactly as much and then stays out of view; at 820 px the tab
row stays in the page; an admin keeps the tab row, named Map and Insights; the map's key
says "Choose them in Insights".
- *(AI teaser)* a reader at 1400 px: the switch reads Map | Insights | AI PRO, still centred,
44 px high; a click on AI changes nothing; at 900 px the switch keeps Map | Insights.
These changes have not had an independent inspection (the owner skipped Codex).
**Not run:** check 1 (a synthetic fourth and fifth site), check 6 (the Viewing period's scope is
unchanged), sign-in through Keycloak, the server's routing of `/views`, sign-in for the read
service, thousands of sites, and dark mode.
## 10. The Petrolyst app's dashboards *(PD-12, PD-13; local, 29 Oct 2026)*
The Petrolyst app (petrolyst-platform `app/`, PD-12) shows Superset dashboards inside its
Insights lens, with guest passes from the Petrolyst server (petrolyst-platform `server/`).
`build_bundle.py` builds them (since PD-13 the only dashboards in the bundle):
| Slug | Content | Filters (preset by the app's filter row, PD-25; Superset's filter column is hidden) |
|---|---|---|
| `overview` | O1–O6: sites by pilot status and by baseline status, litres delivered, fuel cost by currency, electricity billed by currency and basis, litres delivered per month | Sites (`NATIVE_FILTER-sites`, any number, none is all; every chart), Viewing period on O3–O6 |
| `site` | the four §4 detail tabs as Superset's own tabs, the same rows and charts; no D0 (the app shows the site's header) | Site (all charts), Viewing period (as §4: fuel and runtime charts only) |
- **One site dashboard with tabs** *(owner, 29 Oct: "move it back inside the dashboard and make
the tab as they were")*: replaces the four one-tab dashboards `atoma-site-summary`, `-fuel`,
`-runtime`, `-baseline` and the app's per-section pages. Switching tabs needs no reload. The
app opens a site on its first tab; a new viewing period reloads the dashboard on the tab that
was open (the app reads it from Superset and names it in the address, `#TAB-fuel`). Delete
the four old dashboards by hand on an instance that has them.
- **Overview rule** (changes §4 rule 3 for the Overview only): only sums and counts of
recorded values across sites (`OVERVIEW_METRICS`); per-site rates (L/h, L/day) are never
averaged; money is grouped by `currency_text`, a missing currency is its own row, and no
total crosses currencies. `build_bundle.py` checks both.
- **Dashboard CSS** (`EMBEDDED_CSS` and the site dashboard's tab rule in `build_bundle.py`): Superset's roots at their
content's height, so the app's frame can shrink again (PD-25); the site dashboard's tab layers transparent, so its
charts sit as boxes like the Overview's (PD-24); the chart boxes with 16 px corners and the card shadow (PD-26).
- `import_bundle.py --embedded-out FILE --embed-domain URL` makes them embeddable from the app's
origin and writes `{slug: embedding id}` for the Petrolyst server.
- Embedding is switched on in the local stack only (`.local/fmdb-scratch`,
`superset_config_local.py`); production keeps it off until PD-12's open points are settled.
- **Checked (local):** a self-signed guest pass opens its own dashboard and nothing else (another
embedded dashboard and ATOMA: 404; no pass or a wrong audience: 401); the embedded page answers
only to the app's referrer (403 otherwise); chart-data responses to guests carry no SQL.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,328 @@
# Columns of the public.v_* views (migrations 0001-0003), as PostgreSQL reports them.
# build_bundle.py lists every one in its dataset; import_bundle.py reports drift.
# Regenerate after a migration that changes a view (psql -d fuel -AtF $'\t' -c "..."):
# select c.relname, a.attname, format_type(a.atttypid, a.atttypmod)
# from pg_attribute a join pg_class c on c.oid = a.attrelid join pg_namespace n on n.oid = c.relnamespace
# where n.nspname = 'public' and c.relkind = 'v' and c.relname like 'v\_%' and a.attnum > 0
# and not a.attisdropped order by c.relname, a.attnum
v_assumption site_uid character varying(12)
v_assumption site_code character varying(20)
v_assumption site_name character varying(120)
v_assumption site_label character varying(170)
v_assumption operator_uid character varying(12)
v_assumption code character varying(60)
v_assumption topic character varying(30)
v_assumption asset_uid character varying(40)
v_assumption value numeric(10,3)
v_assumption value_low numeric(10,3)
v_assumption value_high numeric(10,3)
v_assumption unit character varying(20)
v_assumption applies_from date
v_assumption applies_to date
v_assumption basis character varying(20)
v_assumption derived_from_fuel boolean
v_assumption status character varying(12)
v_assumption accepted_on date
v_assumption decision_ref character varying(12)
v_assumption rationale character varying(400)
v_assumption contradicted_by character varying(200)
v_assumption members character varying(400)
v_baseline_input site_uid character varying(12)
v_baseline_input site_code character varying(20)
v_baseline_input site_name character varying(120)
v_baseline_input site_label character varying(170)
v_baseline_input operator_uid character varying(12)
v_baseline_input baseline_code character varying(60)
v_baseline_input baseline_status character varying(12)
v_baseline_input interval_label character varying(30)
v_baseline_input start_on date
v_baseline_input end_on date
v_baseline_input included boolean
v_baseline_input exclusion_reason character varying(300)
v_baseline_input fuel_l numeric(10,1)
v_baseline_input runtime_h numeric(10,1)
v_baseline_input days integer
v_baseline_input eligibility_at_computation character varying(12)
v_baseline_input evidence_grade_at_computation character varying(2)
v_baseline_input dependencies_changed boolean
v_baseline_version site_uid character varying(12)
v_baseline_version site_code character varying(20)
v_baseline_version site_name character varying(120)
v_baseline_version site_label character varying(170)
v_baseline_version operator_uid character varying(12)
v_baseline_version code character varying(60)
v_baseline_version metric character varying(40)
v_baseline_version status character varying(12)
v_baseline_version population character varying(200)
v_baseline_version value numeric(8,3)
v_baseline_version range_low numeric(8,3)
v_baseline_version range_high numeric(8,3)
v_baseline_version unit character varying(8)
v_baseline_version window_start date
v_baseline_version window_end date
v_baseline_version fuel_l numeric(10,1)
v_baseline_version runtime_h numeric(10,1)
v_baseline_version matched_days integer
v_baseline_version policy_version character varying(10)
v_baseline_version meets_policy boolean
v_baseline_version policy_check character varying(300)
v_baseline_version evidence_grade character varying(2)
v_baseline_version conditions character varying(200)
v_baseline_version season_note character varying(200)
v_baseline_version approved_on date
v_baseline_version approved_by_role character varying(40)
v_baseline_version decision_ref character varying(12)
v_baseline_version inputs_unchanged boolean
v_baseline_version computed_at timestamp(0) without time zone
v_coverage site_uid character varying(12)
v_coverage site_code character varying(20)
v_coverage site_name character varying(120)
v_coverage site_label character varying(170)
v_coverage operator_uid character varying(12)
v_coverage lane character varying(60)
v_coverage status character varying(40)
v_coverage start_on date
v_coverage end_on date
v_coverage start_ts timestamp(0) without time zone
v_coverage end_ts timestamp(0) without time zone
v_data_quality site_uid character varying(12)
v_data_quality site_code character varying(20)
v_data_quality site_name character varying(120)
v_data_quality site_label character varying(170)
v_data_quality operator_uid character varying(12)
v_data_quality check_order integer
v_data_quality check_code character varying(40)
v_data_quality label character varying(80)
v_data_quality status character varying(10)
v_data_quality detail character varying(300)
v_delivery site_uid character varying(12)
v_delivery site_code character varying(20)
v_delivery site_name character varying(120)
v_delivery site_label character varying(170)
v_delivery operator_uid character varying(12)
v_delivery event_key character varying(80)
v_delivery delivery_date date
v_delivery seq_in_day integer
v_delivery delivered_l numeric(8,1)
v_delivery unit_price numeric(8,2)
v_delivery currency character varying(8)
v_delivery source_ref character varying(60)
v_delivery n_sources integer
v_delivery sources_disagree boolean
v_delivery reviewed boolean
v_electricity_cost site_uid character varying(12)
v_electricity_cost site_code character varying(20)
v_electricity_cost site_name character varying(120)
v_electricity_cost site_label character varying(170)
v_electricity_cost operator_uid character varying(12)
v_electricity_cost bill_month date
v_electricity_cost period_start date
v_electricity_cost period_end date
v_electricity_cost period_known boolean
v_electricity_cost amount numeric(12,2)
v_electricity_cost currency character varying(8)
v_electricity_cost currency_basis character varying(10)
v_electricity_cost kwh numeric(10,1)
v_electricity_cost source_ref character varying(60)
v_electricity_cost claim_status character varying(12)
v_evidence site_uid character varying(12)
v_evidence site_code character varying(20)
v_evidence site_name character varying(120)
v_evidence site_label character varying(170)
v_evidence operator_uid character varying(12)
v_evidence document_key character varying(16)
v_evidence title character varying(200)
v_evidence kind character varying(20)
v_evidence issuer character varying(80)
v_evidence document_date date
v_evidence used_for character varying(200)
v_evidence contains_personal_data boolean
v_hour_meter_evidence site_uid character varying(12)
v_hour_meter_evidence site_code character varying(20)
v_hour_meter_evidence site_name character varying(120)
v_hour_meter_evidence site_label character varying(170)
v_hour_meter_evidence operator_uid character varying(12)
v_hour_meter_evidence meter_label character varying(40)
v_hour_meter_evidence mapped_set character varying(60)
v_hour_meter_evidence reading_date date
v_hour_meter_evidence date_basis character varying(30)
v_hour_meter_evidence hours numeric(9,1)
v_hour_meter_evidence reading_state character varying(16)
v_hour_meter_evidence source_field character varying(16)
v_hour_meter_evidence epoch_no integer
v_hour_meter_evidence claim_status character varying(12)
v_imported_calculation site_uid character varying(12)
v_imported_calculation site_code character varying(20)
v_imported_calculation site_name character varying(120)
v_imported_calculation site_label character varying(170)
v_imported_calculation operator_uid character varying(12)
v_imported_calculation cell_ref character varying(40)
v_imported_calculation source_label character varying(80)
v_imported_calculation formula character varying(200)
v_imported_calculation cached_value numeric(14,6)
v_imported_calculation unit_as_labelled character varying(30)
v_imported_calculation our_reading character varying(300)
v_imported_calculation baseline_use character varying(12)
v_imported_calculation exclusion_reason character varying(300)
v_imported_calculation document_key character varying(16)
v_imported_calculation claim_status character varying(12)
v_interval site_uid character varying(12)
v_interval site_code character varying(20)
v_interval site_name character varying(120)
v_interval site_label character varying(170)
v_interval operator_uid character varying(12)
v_interval interval_label character varying(30)
v_interval start_on date
v_interval end_on date
v_interval days integer
v_interval opening_l numeric(9,1)
v_interval opening_basis character varying(8)
v_interval deliveries_l numeric(9,1)
v_interval withdrawals_l numeric(9,1)
v_interval closing_l numeric(9,1)
v_interval closing_basis character varying(8)
v_interval fuel_used_l numeric(9,1)
v_interval fuel_used_unc_l numeric(9,1)
v_interval fuel_l_per_day numeric(8,1)
v_interval stock_status character varying(40)
v_interval runtime_h numeric(9,1)
v_interval runtime_h_per_day numeric(6,2)
v_interval n_meters integer
v_interval metric character varying(40)
v_interval population_key character varying(200)
v_interval l_per_h numeric(8,3)
v_interval l_per_h_low numeric(8,3)
v_interval l_per_h_high numeric(8,3)
v_interval eligibility character varying(12)
v_interval exclusion_reasons character varying(600)
v_interval evidence_grade character varying(2)
v_interval conditions character varying(200)
v_interval in_current_baseline boolean
v_interval baseline_comparable boolean
v_interval current_baseline_l_per_h numeric(8,3)
v_kpi_measures site_uid character varying(12)
v_kpi_measures site_code character varying(20)
v_kpi_measures site_name character varying(120)
v_kpi_measures site_label character varying(170)
v_kpi_measures operator_uid character varying(12)
v_kpi_measures measure_order integer
v_kpi_measures measure character varying(60)
v_kpi_measures value numeric(12,2)
v_kpi_measures unit character varying(12)
v_kpi_measures value_kind character varying(16)
v_kpi_measures window_start date
v_kpi_measures window_end date
v_kpi_measures note character varying(200)
v_modelled_lph site_uid character varying(12)
v_modelled_lph site_code character varying(20)
v_modelled_lph site_name character varying(120)
v_modelled_lph site_label character varying(170)
v_modelled_lph operator_uid character varying(12)
v_modelled_lph assumption_code character varying(60)
v_modelled_lph status character varying(12)
v_modelled_lph basis character varying(20)
v_modelled_lph h_per_day numeric(5,2)
v_modelled_lph h_per_day_low numeric(5,2)
v_modelled_lph h_per_day_high numeric(5,2)
v_modelled_lph window_start date
v_modelled_lph window_end date
v_modelled_lph matched_days integer
v_modelled_lph l_per_day numeric(7,1)
v_modelled_lph modelled_l_per_h numeric(6,2)
v_modelled_lph modelled_l_per_h_low numeric(6,2)
v_modelled_lph modelled_l_per_h_high numeric(6,2)
v_modelled_lph value_kind character varying(70)
v_reconciliation site_uid character varying(12)
v_reconciliation site_code character varying(20)
v_reconciliation site_name character varying(120)
v_reconciliation site_label character varying(170)
v_reconciliation operator_uid character varying(12)
v_reconciliation interval_label character varying(30)
v_reconciliation start_on date
v_reconciliation end_on date
v_reconciliation stock_status character varying(40)
v_reconciliation endpoint_times_known boolean
v_reconciliation asset_uid character varying(40)
v_reconciliation placed_throughout boolean
v_reconciliation stock_balance_l numeric(9,1)
v_reconciliation engine_l numeric(9,1)
v_reconciliation covered_h numeric(9,1)
v_reconciliation interval_h numeric(9,1)
v_reconciliation engine_all_generators_l numeric(9,1)
v_reconciliation all_generators_metered boolean
v_reconciliation difference_l numeric(9,1)
v_reconciliation difference_pct numeric(7,1)
v_runtime site_uid character varying(12)
v_runtime site_code character varying(20)
v_runtime site_name character varying(120)
v_runtime site_label character varying(170)
v_runtime operator_uid character varying(12)
v_runtime meter_label character varying(40)
v_runtime mapped_set character varying(60)
v_runtime interval_label character varying(80)
v_runtime start_on date
v_runtime end_on date
v_runtime days integer
v_runtime runtime_h numeric(9,1)
v_runtime h_per_day numeric(6,2)
v_runtime pair_valid boolean
v_runtime same_epoch boolean
v_runtime monotonic boolean
v_runtime over_24h_per_day boolean
v_runtime reviewed boolean
v_site site_uid character varying(12)
v_site site_code character varying(20)
v_site site_name character varying(120)
v_site site_label character varying(170)
v_site operator_uid character varying(12)
v_site zone character varying(40)
v_site province character varying(40)
v_site district character varying(60)
v_site pilot_status character varying(12)
v_site latitude numeric(9,6)
v_site longitude numeric(9,6)
v_site location_status character varying(20)
v_site location_basis character varying(20)
v_site location_precision_m integer
v_site baseline_status character varying(24)
v_site baseline_status_label character varying(40)
v_site baseline_validity_note character varying(60)
v_site lph_estimate numeric(6,2)
v_site lph_method character varying(60)
v_site lph_window_start date
v_site lph_window_end date
v_site baseline_conditions character varying(200)
v_site baseline_decision_ref character varying(12)
v_site fuel_l_per_calendar_day numeric(7,1)
v_site latest_record_on date
v_spec_reference site_uid character varying(12)
v_spec_reference site_code character varying(20)
v_spec_reference site_name character varying(120)
v_spec_reference site_label character varying(170)
v_spec_reference operator_uid character varying(12)
v_spec_reference slot character varying(20)
v_spec_reference asset_uid character varying(40)
v_spec_reference asset_kind character varying(20)
v_spec_reference make_model character varying(120)
v_spec_reference attribute character varying(40)
v_spec_reference load_pct numeric(5,1)
v_spec_reference value numeric(12,3)
v_spec_reference value_text character varying(120)
v_spec_reference unit character varying(20)
v_spec_reference basis character varying(30)
v_spec_reference applicability character varying(12)
v_spec_reference applicability_note character varying(300)
v_spec_reference value_kind character varying(30)
v_spec_reference claim_status character varying(12)
v_stock_reading site_uid character varying(12)
v_stock_reading site_code character varying(20)
v_stock_reading site_name character varying(120)
v_stock_reading site_label character varying(170)
v_stock_reading operator_uid character varying(12)
v_stock_reading observed_on date
v_stock_reading seq_in_day integer
v_stock_reading reading_type character varying(20)
v_stock_reading litres numeric(8,1)
v_stock_reading value_kind character varying(12)
v_stock_reading spread_l numeric(8,1)
v_stock_reading reviewed boolean
Can't render this file because it contains an unexpected character in line 3 and column 80.
@@ -0,0 +1,141 @@
"""Import the ATOMA dashboards bundle into Superset, overwriting what it defines.
Runs inside the Superset container, with the image's Python:
docker cp import_bundle.py <container>:/tmp/ && docker cp atoma-dashboards.zip <container>:/tmp/
docker exec <container> /app/.venv/bin/python /tmp/import_bundle.py --show-database
docker exec <container> /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip --user <username>
`superset import-dashboards` imports a dashboard's datasets and charts with
overwrite off, so an existing chart or dataset keeps its old definition. This
script runs the dataset, chart and dashboard importers in turn, each with
overwrite on (the bundle's metadata.yaml type is set to what each importer
expects). A dataset import replaces the column list, so columns missing from
the bundle are dropped; afterwards each dataset's columns are compared with the
live view and any drift is reported (exit status 2).
The database is never created or changed: the bundle's database uuid must
already exist under the name "Fuel", otherwise nothing is imported.
The dashboards (overview, site) get no role: admins see them in Superset, and
the Petrolyst app shows them with guest passes (PD-12). With --embedded-out FILE
--embed-domain URL, each of them is made embeddable from that origin (its embedding id stays
the same across imports), and FILE receives {slug: embedding id} for the Petrolyst server:
docker exec <container> /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip \
--user <username> --embedded-out /tmp/embedded-dashboards.json --embed-domain http://127.0.0.1:8098
"""
from __future__ import annotations
import argparse
import json
import sys
import zipfile
import yaml
DATABASE_NAME = "Fuel"
EMBEDDED_SLUGS = {"overview", "site"}
STEPS = [("datasets", "SqlaTable"), ("charts", "Slice"), ("dashboards", "Dashboard")]
def main() -> int:
parser = argparse.ArgumentParser(description="Import the ATOMA dashboards bundle.")
parser.add_argument("bundle", nargs="?")
parser.add_argument("--user", help="existing Superset user who will own new objects")
parser.add_argument("--show-database", action="store_true",
help=f'print the uuid of "{DATABASE_NAME}" and stop')
parser.add_argument("--embedded-out", help="write {slug: embedding id} of the app's dashboards")
parser.add_argument("--embed-domain", help="the app's origin, e.g. http://127.0.0.1:8098")
args = parser.parse_args()
if not args.show_database and not (args.bundle and args.user):
parser.error("give a bundle and --user, or --show-database")
if bool(args.embedded_out) != bool(args.embed_domain):
parser.error("--embedded-out and --embed-domain go together")
from superset.app import create_app
app = create_app()
with app.app_context():
from superset import db, security_manager
from superset.commands.chart.importers.v1 import ImportChartsCommand
from superset.commands.dashboard.importers.v1 import ImportDashboardsCommand
from superset.commands.dataset.importers.v1 import ImportDatasetsCommand
from superset.commands.importers.v1.utils import get_contents_from_bundle
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.models.dashboard import Dashboard
from superset.utils.core import override_user
if args.show_database:
found = db.session.query(Database).filter_by(database_name=DATABASE_NAME).all()
for database in found:
print(database.uuid)
return 0 if found else 1
user = security_manager.find_user(username=args.user)
if user is None:
print(f"no Superset user {args.user!r}", file=sys.stderr)
return 1
with zipfile.ZipFile(args.bundle) as bundle:
contents = get_contents_from_bundle(bundle)
databases = [yaml.safe_load(t) for p, t in contents.items() if p.startswith("databases/")]
for config in databases:
database = db.session.query(Database).filter_by(uuid=config["uuid"]).one_or_none()
if database is None or database.database_name != DATABASE_NAME:
print(f'database {config["uuid"]} is not "{DATABASE_NAME}" here; '
"rebuild the bundle with the uuid from --show-database", file=sys.stderr)
return 1
metadata = yaml.safe_load(contents["metadata.yaml"])
commands = {"datasets": ImportDatasetsCommand, "charts": ImportChartsCommand,
"dashboards": ImportDashboardsCommand}
with override_user(user):
for step, model in STEPS:
step_contents = dict(contents)
step_contents["metadata.yaml"] = yaml.safe_dump({**metadata, "type": model})
commands[step](step_contents, overwrite=True).run()
count = sum(p.startswith(step + "/") for p in contents)
print(f"{step}: {count} imported (overwrite)")
embedded: dict[str, str] = {}
for path, text in contents.items():
if path.startswith("dashboards/"):
dashboard = db.session.query(Dashboard).filter_by(
uuid=yaml.safe_load(text)["uuid"]).one()
if dashboard.slug in EMBEDDED_SLUGS and args.embedded_out:
from superset.daos.dashboard import EmbeddedDashboardDAO
config = EmbeddedDashboardDAO.upsert(dashboard, [args.embed_domain])
db.session.flush()
embedded[dashboard.slug] = str(config.uuid)
# An empty or short list would stop the app's dashboards from opening.
if args.embedded_out and set(embedded) != EMBEDDED_SLUGS:
db.session.rollback()
print(f"embedded: expected {sorted(EMBEDDED_SLUGS)}, got {sorted(embedded)}", file=sys.stderr)
return 3
db.session.commit()
if args.embedded_out:
with open(args.embedded_out, "w") as out:
json.dump(dict(sorted(embedded.items())), out, indent=2)
print(f"embedded: {len(embedded)} dashboard(s) for {args.embed_domain}, "
f"ids in {args.embedded_out}")
drift = 0
dataset_uuids = [yaml.safe_load(t)["uuid"] for p, t in contents.items()
if p.startswith("datasets/")]
for dataset_uuid in dataset_uuids:
dataset = db.session.query(SqlaTable).filter_by(uuid=dataset_uuid).one()
physical = {c.column_name for c in dataset.columns if not c.expression}
live = {c["column_name"] for c in dataset.external_metadata()}
if physical != live:
drift += 1
print(f"drift {dataset.table_name}: only in bundle {sorted(physical - live)}, "
f"only in view {sorted(live - physical)}")
print(f"column drift: {drift} dataset(s)")
return 2 if drift else 0
if __name__ == "__main__":
sys.exit(main())
+11 -1
View File
@@ -1,7 +1,7 @@
"""Keycloak sign-in for Superset at atoma.petrolyst.com. """Keycloak sign-in for Superset at atoma.petrolyst.com.
Set as CUSTOM_SECURITY_MANAGER in superset_config.py. Checked against Set as CUSTOM_SECURITY_MANAGER in superset_config.py. Checked against
Flask-AppBuilder 5.0.2 as bundled in apache/superset:6.1.0. Three changes to Flask-AppBuilder 5.0.2 as bundled in apache/superset:6.1.0. Four changes to
the stock behaviour: the stock behaviour:
- User info and roles come from the ID token that Keycloak issues for client - User info and roles come from the ID token that Keycloak issues for client
@@ -11,6 +11,8 @@ the stock behaviour:
login page on /login/, which only shows a "Sign in with keycloak" button. login page on /login/, which only shows a "Sign in with keycloak" button.
- /logout/ also ends the Keycloak session, so the next visit asks for the - /logout/ also ends the Keycloak session, so the next visit asks for the
password and OTP again. password and OTP again.
- A Keycloak user with no mapped Superset role is refused, and no account is
made. Customers' users belong in the Petrolyst app, never here (PD-21).
""" """
import logging import logging
@@ -70,6 +72,14 @@ class PetrolystSecurityManager(SupersetSecurityManager):
# FAB's OAuth view, and that page wins the route. # FAB's OAuth view, and that page wins the route.
register_superset_auth_view = False register_superset_auth_view = False
def auth_user_oauth(self, userinfo: dict[str, Any]) -> Any:
# Only the mapped roles count: FAB's own calculation always adds the
# registration role, so it is never empty.
if not self.get_roles_from_keys(userinfo.get("role_keys", [])):
logger.warning("Keycloak sign-in refused: no mapped Superset role")
return None
return super().auth_user_oauth(userinfo)
def oauth_user_info( def oauth_user_info(
self, provider: str, response: Optional[dict[str, Any]] = None self, provider: str, response: Optional[dict[str, Any]] = None
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -0,0 +1,81 @@
"""The role "Petrolyst Guest": what the Petrolyst app's embedded dashboards may do (PD-12).
Superset gives every embedded viewer (a guest pass signed for one dashboard) the role named by
GUEST_ROLE_NAME. Which dashboard and which customer's rows a guest sees come from the pass, not from
the role. Superset
leaves the SQL out of chart-data responses for guests.
Embedding is switched on in the local stack only (.local/fmdb-scratch, superset_config_local.py),
so this script is not part of init.sh; run it by hand inside the Superset container, once, and
again whenever PERMISSIONS change:
docker exec <superset> /app/.venv/bin/python /app/petrolyst-roles/petrolyst_guest.py
Creates the role if needed and gives it exactly PERMISSIONS: missing ones are added, any other
is removed (also one added by hand in the UI). A second run changes nothing.
The list was established on the local stack by reading the ATOMA dashboard as a reader (the
former "ATOMA Reader" role, retired with PD-13): everything a dashboard asks the server for, and
nothing more. No menu access, nothing to write, no Explore, CSV, sharing, drill or query view.
"""
from __future__ import annotations
import sys
ROLE = "Petrolyst Guest"
PERMISSIONS = [
("can_read", "Dashboard"), # the dashboard and its charts' definitions
("can_dashboard", "Superset"), # the dashboard page
("can_dashboard_permalink", "Superset"), # a dashboard permalink
("can_read", "Chart"), # chart data
("can_time_range", "Api"), # the Viewing period filter
("can_read", "DashboardFilterStateRestApi"), # native filter state
("can_write", "DashboardFilterStateRestApi"),
("can_read", "DashboardPermalinkRestApi"),
("can_read", "CurrentUserRestApi"), # who is signed in
("can_read", "SecurityRestApi"), # the CSRF token
("can_get", "MenuApi"),
("can_log", "Superset"), # Superset's event log
]
def sync(name: str, permissions: list[tuple[str, str]]) -> int:
"""Give the role `name` exactly `permissions`, creating it if needed."""
from superset.app import create_app
app = create_app()
with app.app_context():
from superset import db, security_manager as sm
wanted = []
for permission, view in permissions:
pvm = sm.find_permission_view_menu(permission, view)
if pvm is None:
print(f"no permission {permission} on {view}: run `superset init` first?",
file=sys.stderr)
return 1
wanted.append(pvm)
role = sm.find_role(name) or sm.add_role(name)
added = [pvm for pvm in wanted if pvm not in role.permissions]
removed = [pvm for pvm in role.permissions if pvm not in wanted]
for pvm in added:
sm.add_permission_role(role, pvm)
for pvm in removed:
sm.del_permission_role(role, pvm)
db.session.commit()
print(f'role "{name}": {len(wanted)} permissions, {len(added)} added, '
f"{len(removed)} removed")
for pvm in removed:
print(f" removed {pvm.permission.name} on {pvm.view_menu.name}")
return 0
def main() -> int:
return sync(ROLE, PERMISSIONS)
if __name__ == "__main__":
sys.exit(main())
+47 -18
View File
@@ -45,11 +45,26 @@ SQLALCHEMY_DATABASE_URI = URL.create(
database="superset", database="superset",
).render_as_string(hide_password=False) ).render_as_string(hide_password=False)
# --- The data: fuel-db ----------------------------------------------------------
# Superset reads fuel-db directly as the read-only role fuel_ro;
# superset-init registers that connection, "Fuel", without a password. Passwords of data
# connections come from secret files at connect time, so none is kept in Superset's own
# database. A connection added in the UI gets no password this way.
_DATABASE_PASSWORD_SECRETS = {"fuel_ro": "fuel_db_ro_password"}
def _database_password(url: URL) -> str | None:
secret = _DATABASE_PASSWORD_SECRETS.get(url.username or "")
return _read_secret(secret) if secret else None
SQLALCHEMY_CUSTOM_PASSWORD_STORE = _database_password
# --- Sign-in through Keycloak (client "petrolyst") ---------------------------- # --- Sign-in through Keycloak (client "petrolyst") ----------------------------
# Users and roles are managed in Keycloak; there is no local admin account. # Users and roles are managed in Keycloak; there is no local admin account.
# Break-glass access: `superset fab ...` inside the container on the server. # Break-glass access: `superset fab ...` inside the container on the server.
KEYCLOAK_REALM_URL = os.environ.get( KEYCLOAK_REALM_URL = os.environ.get(
"KEYCLOAK_REALM_URL", "https://iam.thedigitalbridge.io/realms/thedigitalbridge" "KEYCLOAK_REALM_URL", "https://iam.thedigitalbridge.io/realms/petrolyst"
).rstrip("/") ).rstrip("/")
AUTH_TYPE = AUTH_OAUTH AUTH_TYPE = AUTH_OAUTH
@@ -79,15 +94,26 @@ AUTH_ROLES_MAPPING = {
"superset-admin": ["Admin"], "superset-admin": ["Admin"],
"superset-editor": ["Alpha"], "superset-editor": ["Alpha"],
"superset-viewer": ["Gamma"], "superset-viewer": ["Gamma"],
# Customers' users have a role of client "petrolyst-app", not of this one: refused here.
} }
AUTH_ROLES_SYNC_AT_LOGIN = True AUTH_ROLES_SYNC_AT_LOGIN = True
AUTH_USER_REGISTRATION = True AUTH_USER_REGISTRATION = True
# Public has no permissions: a Keycloak user without a Superset role signs in # Public has no permissions. A Keycloak user without a mapped role never gets
# and sees nothing. # this far: PetrolystSecurityManager refuses the sign-in and makes no account.
AUTH_USER_REGISTRATION_ROLE = "Public" AUTH_USER_REGISTRATION_ROLE = "Public"
# Viewers (Gamma) see a dashboard once it lists their role. # Viewers (Gamma) see a dashboard once it lists their role.
FEATURE_FLAGS = {"DASHBOARD_RBAC": True} # FILTERBAR_CLOSED_BY_DEFAULT: a dashboard's filter column opens collapsed, a
# narrow strip with an expand button (owner, 29 Oct); ?expand_filters=true
# opens it from a link.
FEATURE_FLAGS = {"DASHBOARD_RBAC": True, "FILTERBAR_CLOSED_BY_DEFAULT": True}
# deck.gl maps: OpenStreetMap raster tiles only. The default list adds Mapbox
# styles, which fail without MAPBOX_API_KEY. The default CSP already allows the
# tile host.
DECKGL_BASE_MAP = [
["tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap"],
]
# --- Behind Traefik ----------------------------------------------------------- # --- Behind Traefik -----------------------------------------------------------
ENABLE_PROXY_FIX = True ENABLE_PROXY_FIX = True
@@ -128,12 +154,12 @@ EXPLORE_FORM_DATA_CACHE_CONFIG = {
RATELIMIT_STORAGE_URI = f"{REDIS_URL}/1" RATELIMIT_STORAGE_URI = f"{REDIS_URL}/1"
# --- Petrolyst look and feel -------------------------------------------------- # --- Petrolyst look and feel --------------------------------------------------
# Values from petrolyst-ui/design/brand/07_UI_Tokens/petrolyst_tokens.css. # Values from petrolyst-web/design/brand/07_UI_Tokens/petrolyst_tokens.css.
# Assets live in assets/branding/ and are served from /static/assets/branding/. # Assets live in assets/branding/ and are served from /static/assets/branding/.
BRANDING = "/static/assets/branding" BRANDING = "/static/assets/branding"
APP_NAME = "Petrolyst" APP_NAME = "Petrolyst"
APP_ICON = f"{BRANDING}/petrolyst_logo_standard_fullcolor_no_tagline.svg" APP_ICON = f"{BRANDING}/petrolyst_wordmark_fullcolor.svg"
FAVICONS = [ FAVICONS = [
{"href": f"{BRANDING}/favicon.svg", "type": "image/svg+xml"}, {"href": f"{BRANDING}/favicon.svg", "type": "image/svg+xml"},
{"href": f"{BRANDING}/favicon.ico", "type": "image/x-icon"}, {"href": f"{BRANDING}/favicon.ico", "type": "image/x-icon"},
@@ -147,10 +173,13 @@ THEME_DEFAULT["token"].update(
"brandLogoAlt": "Petrolyst", "brandLogoAlt": "Petrolyst",
"brandLogoUrl": APP_ICON, "brandLogoUrl": APP_ICON,
"brandLogoHref": "/", "brandLogoHref": "/",
# The standard logo is stacked (mark over wordmark); 40px keeps the Y # The wordmark alone (owner, 29 Oct): in the stacked logo, at 40px, its
# legible and, with the margin, the 60px navbar height. # letters were about 5px high. Its own Y carries the brand accent. 24px
"brandLogoHeight": "40px", # (letters about 20px) and the margin fill the 60px navbar height. On the
"brandLogoMargin": "10px 0", # left, 15px more than the navbar's 16px: the P (1.4px inside the image)
# lines up with the dashboard's content at 32px.
"brandLogoHeight": "24px",
"brandLogoMargin": "18px 0 18px 15px",
# Action teal, not the raw brand teal #009688: the brand guide keeps # Action teal, not the raw brand teal #009688: the brand guide keeps
# raw teal off small text and controls. # raw teal off small text and controls.
"colorPrimary": "#00695C", "colorPrimary": "#00695C",
@@ -160,8 +189,8 @@ THEME_DEFAULT["token"].update(
"colorError": "#B3261E", "colorError": "#B3261E",
"colorInfo": "#2563EB", "colorInfo": "#2563EB",
"colorTextBase": "#0B1D33", "colorTextBase": "#0B1D33",
"colorBgLayout": "#F8FAFC", "colorBgLayout": "#F7F9FA",
"colorBorder": "#E2E8F0", "colorBorder": "#DDE5E9",
"borderRadius": 8, "borderRadius": 8,
"fontFamily": "Inter, system-ui, sans-serif", "fontFamily": "Inter, system-ui, sans-serif",
} }
@@ -171,7 +200,7 @@ THEME_DARK = deepcopy(THEME_DEFAULT)
THEME_DARK["algorithm"] = "dark" THEME_DARK["algorithm"] = "dark"
THEME_DARK["token"].update( THEME_DARK["token"].update(
{ {
"brandLogoUrl": f"{BRANDING}/petrolyst_logo_standard_white_no_tagline.svg", "brandLogoUrl": f"{BRANDING}/petrolyst_wordmark_white_y.svg",
"colorPrimary": "#35C5B6", "colorPrimary": "#35C5B6",
"colorLink": "#35C5B6", "colorLink": "#35C5B6",
"colorSuccess": "#35C5B6", "colorSuccess": "#35C5B6",
@@ -179,10 +208,10 @@ THEME_DARK["token"].update(
"colorError": "#FF6B6B", "colorError": "#FF6B6B",
"colorInfo": "#60A5FA", "colorInfo": "#60A5FA",
"colorTextBase": "#F8FAFC", "colorTextBase": "#F8FAFC",
"colorBgBase": "#071425", "colorBgBase": "#1F2A33",
"colorBgLayout": "#071425", "colorBgLayout": "#1F2A33",
"colorBgContainer": "#12263D", "colorBgContainer": "#2A3945",
"colorBorder": "#526D82", "colorBorder": "#76828E",
"colorEditorSelection": superset_defaults.THEME_DARK["token"][ "colorEditorSelection": superset_defaults.THEME_DARK["token"][
"colorEditorSelection" "colorEditorSelection"
], ],
@@ -190,7 +219,7 @@ THEME_DARK["token"].update(
) )
# Chart colours. Validated with the dataviz skill's validator on both chart # Chart colours. Validated with the dataviz skill's validator on both chart
# surfaces (#FFFFFF light, #12263D dark): all slots in both lightness bands and # surfaces (#FFFFFF light, #2A3945 dark, PD-26): all slots in both lightness bands and
# >= 3:1; worst adjacent CVD dE 13.7, normal-vision dE 26.2; the first three # >= 3:1; worst adjacent CVD dE 13.7, normal-vision dE 26.2; the first three
# slots also pass all-pairs (scatter, maps). Brand teal leads; brand cyan # slots also pass all-pairs (scatter, maps). Brand teal leads; brand cyan
# #00B6E6 is snapped to #0097C4 because it is too light for the dark surface # #00B6E6 is snapped to #0097C4 because it is too light for the dark surface
+16 -29
View File
@@ -1,12 +1,11 @@
# Fuel-monitoring analytics: Superset on atoma.petrolyst.com, reading the # Fuel-monitoring analytics: Superset on atoma.petrolyst.com, reading the
# operational store (fuel-db) through Trino. Run only through deploy/deploy.sh # operational store (fuel-db) directly as the read-only role fuel_ro. Run only through deploy/deploy.sh or
# or deploy/rollback.sh: they export SUPERSET_IMAGE_TAG and TRINO_CONFIG_HASH # deploy/rollback.sh: they export SUPERSET_IMAGE_TAG from the release's
# from the release's ARTIFACTS file, and the :? guards below stop Compose from # ARTIFACTS file, and the :? guard below stops Compose from running without it.
# running with either unset. name: petrolyst
name: fuel-monitoring
x-superset-env: &superset-env x-superset-env: &superset-env
KEYCLOAK_REALM_URL: ${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/thedigitalbridge} KEYCLOAK_REALM_URL: ${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/petrolyst}
x-superset: &superset x-superset: &superset
image: fuel-superset:${SUPERSET_IMAGE_TAG:?SUPERSET_IMAGE_TAG unset - run through deploy/deploy.sh} image: fuel-superset:${SUPERSET_IMAGE_TAG:?SUPERSET_IMAGE_TAG unset - run through deploy/deploy.sh}
@@ -15,6 +14,8 @@ x-superset: &superset
- superset_secret_key - superset_secret_key
- superset_db_password - superset_db_password
- keycloak_petrolyst_client_secret - keycloak_petrolyst_client_secret
# fuel_ro's password, read at connect time (superset_config.py)
- fuel_db_ro_password
services: services:
superset: superset:
@@ -100,25 +101,6 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
trino:
image: trinodb/trino:484
env_file:
- ${PETROLYST_ROOT:-/opt/petrolyst}/env/fuel-monitoring.env
volumes:
- ./trino/etc:/etc/trino:ro
labels:
# Compose cannot see content changes in a bind mount; this label changes
# with trino/etc, so exactly a config change recreates the container.
petrolyst.config-hash: ${TRINO_CONFIG_HASH:?TRINO_CONFIG_HASH unset - run through deploy/deploy.sh}
depends_on:
fuel-db:
condition: service_healthy
networks:
- backend
restart: unless-stopped
mem_limit: 3g
# The image's own healthcheck (/usr/lib/trino/bin/health-check) applies.
fuel-db: fuel-db:
image: timescale/timescaledb:2.31.0-pg17 image: timescale/timescaledb:2.31.0-pg17
environment: environment:
@@ -128,7 +110,7 @@ services:
TS_TUNE_NUM_CPUS: "2" TS_TUNE_NUM_CPUS: "2"
secrets: secrets:
- fuel_db_password - fuel_db_password
- fuel_db_trino_password - fuel_db_ro_password
volumes: volumes:
- fuel_db_data:/var/lib/postgresql/data - fuel_db_data:/var/lib/postgresql/data
# A single file, not the folder: the image's own TimescaleDB init scripts # A single file, not the folder: the image's own TimescaleDB init scripts
@@ -157,9 +139,14 @@ networks:
# so fuel-db's loopback port works. # so fuel-db's loopback port works.
fuel-db-tunnel: {} fuel-db-tunnel: {}
# Created under the project's first name, fuel-monitoring; named explicitly so the
# data stayed when the project became petrolyst (29 Oct 2026). Compose warns once
# that they belong to another project; that is expected.
volumes: volumes:
superset_db_data: superset_db_data:
name: fuel-monitoring_superset_db_data
fuel_db_data: fuel_db_data:
name: fuel-monitoring_fuel_db_data
secrets: secrets:
superset_secret_key: superset_secret_key:
@@ -168,7 +155,7 @@ secrets:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/superset_db_password file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/superset_db_password
fuel_db_password: fuel_db_password:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_password file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_password
fuel_db_trino_password: fuel_db_ro_password:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_trino_password file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_ro_password
keycloak_petrolyst_client_secret: keycloak_petrolyst_client_secret:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/shared/keycloak_petrolyst_client_secret file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/shared/keycloak_petrolyst_realm_client_secret
@@ -5,21 +5,24 @@
# Loaders log in as their own role but act as fuel_owner (README # Loaders log in as their own role but act as fuel_owner (README
# "Data loaders"): the default privileges below cover only what # "Data loaders"): the default privileges below cover only what
# fuel_owner itself creates, not what its members create. # fuel_owner itself creates, not what its members create.
# trino_ro what Trino connects as: read-only, also on tables created later. # fuel_ro what Superset and the Petrolyst server read as: SELECT on public,
# also on tables created later. Read-only by default as well: a
# second lock behind the grants.
set -euo pipefail set -euo pipefail
trino_password=$(cat /run/secrets/fuel_db_trino_password) ro_password=$(cat /run/secrets/fuel_db_ro_password)
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
-v trino_password="$trino_password" <<'SQL' -v ro_password="$ro_password" <<'SQL'
CREATE ROLE fuel_owner NOLOGIN; CREATE ROLE fuel_owner NOLOGIN;
ALTER DATABASE fuel OWNER TO fuel_owner; ALTER DATABASE fuel OWNER TO fuel_owner;
REVOKE CREATE ON SCHEMA public FROM PUBLIC; REVOKE CREATE ON SCHEMA public FROM PUBLIC;
CREATE ROLE trino_ro LOGIN PASSWORD :'trino_password'; CREATE ROLE fuel_ro LOGIN PASSWORD :'ro_password';
GRANT CONNECT ON DATABASE fuel TO trino_ro; ALTER ROLE fuel_ro SET default_transaction_read_only = on;
GRANT USAGE ON SCHEMA public TO trino_ro; GRANT CONNECT ON DATABASE fuel TO fuel_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO trino_ro; GRANT USAGE ON SCHEMA public TO fuel_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO fuel_ro;
ALTER DEFAULT PRIVILEGES FOR ROLE fuel_owner IN SCHEMA public ALTER DEFAULT PRIVILEGES FOR ROLE fuel_owner IN SCHEMA public
GRANT SELECT ON TABLES TO trino_ro; GRANT SELECT ON TABLES TO fuel_ro;
SQL SQL
@@ -2,7 +2,9 @@
-- DRAFT, not applied. Applied by hand by a loader acting as fuel_owner (README in this folder); -- DRAFT, not applied. Applied by hand by a loader acting as fuel_owner (README in this folder);
-- deploy.sh never touches fuel-db. Design and rationale: fuel-monitoring -- deploy.sh never touches fuel-db. Design and rationale: fuel-monitoring
-- .context/plans/2026-10-24-fuel-db-baseline-dashboard.md (sections cited as "design §n"). -- .context/plans/2026-10-24-fuel-db-baseline-dashboard.md (sections cited as "design §n").
-- Order: 0001, 0002, 0003, each in its own transaction. -- Order: 0001 to 0004, each in its own transaction.
-- Every table but fm.asset_kind carries operator_uid, the customer (PD-16). A child names its
-- parent and the customer together, so it cannot point at another customer's parent.
BEGIN; BEGIN;
CREATE SCHEMA fm AUTHORIZATION fuel_owner; CREATE SCHEMA fm AUTHORIZATION fuel_owner;
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA fm; -- trusted extension; for EXCLUDE on text CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA fm; -- trusted extension; for EXCLUDE on text
@@ -10,9 +12,21 @@ CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA fm; -- trusted extension
-- anyway, and the local test applies this file in a session whose search_path is the loader's default. -- anyway, and the local test applies this file in a session whose search_path is the loader's default.
SET LOCAL search_path = fm, pg_catalog, public; SET LOCAL search_path = fm, pg_catalog, public;
-- Customers -------------------------------------------------------------------------------
CREATE TABLE fm.operator (
operator_uid text PRIMARY KEY CHECK (operator_uid ~ '^OPR-[0-9]{4}$'),
name text NOT NULL,
label_issuer text NOT NULL, -- whose labels this customer's people see (holder_alias.issuer)
programme text NOT NULL, -- the programme v_site reports (participation.programme)
timezone text NOT NULL, -- for a holder with no place, or a place without its own
keycloak_group text NOT NULL UNIQUE -- the top-level Keycloak group of this customer's people (PD-21)
CHECK (keycloak_group ~ '^/[^/]+$')
);
-- Provenance ------------------------------------------------------------------------------ -- Provenance ------------------------------------------------------------------------------
CREATE TABLE fm.source_document ( CREATE TABLE fm.source_document (
sha256 text PRIMARY KEY CHECK (sha256 ~ '^[0-9a-f]{64}$'), sha256 text PRIMARY KEY CHECK (sha256 ~ '^[0-9a-f]{64}$'),
operator_uid text NOT NULL REFERENCES fm.operator,
source_family text NOT NULL, -- stable across versions: '<operator>-<workbook>', 'site-yaml:SITE-0001' source_family text NOT NULL, -- stable across versions: '<operator>-<workbook>', 'site-yaml:SITE-0001'
legacy_key16 text UNIQUE CHECK (legacy_key16 ~ '^[0-9a-f]{16}$'), -- catalog.yaml key legacy_key16 text UNIQUE CHECK (legacy_key16 ~ '^[0-9a-f]{16}$'), -- catalog.yaml key
kind text NOT NULL CHECK (kind IN ('delivery_form','site_logbook','operator_workbook', kind text NOT NULL CHECK (kind IN ('delivery_form','site_logbook','operator_workbook',
@@ -23,12 +37,15 @@ CREATE TABLE fm.source_document (
corpus_path text, -- NULL when held outside the repository corpus_path text, -- NULL when held outside the repository
transcription_path text, -- the corpus note that transcribes it transcription_path text, -- the corpus note that transcribes it
contains_personal_data boolean NOT NULL, contains_personal_data boolean NOT NULL,
supersedes_sha256 text UNIQUE REFERENCES fm.source_document, supersedes_sha256 text UNIQUE,
null_reasons jsonb NOT NULL DEFAULT '{}', null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK (corpus_path IS NOT NULL OR transcription_path IS NOT NULL) CHECK (corpus_path IS NOT NULL OR transcription_path IS NOT NULL),
UNIQUE (operator_uid, sha256),
FOREIGN KEY (operator_uid, supersedes_sha256) REFERENCES fm.source_document (operator_uid, sha256)
); );
CREATE TABLE fm.import_batch ( CREATE TABLE fm.import_batch (
import_batch_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, import_batch_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_kind text NOT NULL CHECK (source_kind IN ('site_yaml','operator_workbook','catalog','review_manifest','baseline_run')), source_kind text NOT NULL CHECK (source_kind IN ('site_yaml','operator_workbook','catalog','review_manifest','baseline_run')),
source_path text NOT NULL, source_path text NOT NULL,
source_sha256 text NOT NULL CHECK (source_sha256 ~ '^[0-9a-f]{64}$'), source_sha256 text NOT NULL CHECK (source_sha256 ~ '^[0-9a-f]{64}$'),
@@ -37,33 +54,86 @@ CREATE TABLE fm.import_batch (
started_at timestamptz NOT NULL DEFAULT now(), started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz, finished_at timestamptz,
status text NOT NULL DEFAULT 'running' CHECK (status IN ('running','loaded','failed')), status text NOT NULL DEFAULT 'running' CHECK (status IN ('running','loaded','failed')),
report jsonb NOT NULL DEFAULT '{}' -- counts, unmapped paths, conflicts report jsonb NOT NULL DEFAULT '{}', -- counts, unmapped paths, conflicts
UNIQUE (operator_uid, import_batch_id)
); );
CREATE TABLE fm.import_record ( CREATE TABLE fm.import_record (
import_record_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, import_record_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
logical_key text NOT NULL, -- = source_key: '<family>:delivery:SITE-0001:2026-01-31:1' logical_key text NOT NULL, -- = source_key: '<family>:delivery:SITE-0001:2026-01-31:1'
source_locator text NOT NULL, -- YAML path or sheet!range as read source_locator text NOT NULL, -- YAML path or sheet!range as read
payload jsonb NOT NULL, -- original values, strings as written, 'UNKNOWN' kept payload jsonb NOT NULL, -- original values, strings as written, 'UNKNOWN' kept
payload_sha256 text NOT NULL, payload_sha256 text NOT NULL,
first_batch_id bigint NOT NULL REFERENCES fm.import_batch, first_batch_id bigint NOT NULL,
last_seen_batch_id bigint NOT NULL REFERENCES fm.import_batch, last_seen_batch_id bigint NOT NULL,
UNIQUE (logical_key, payload_sha256) UNIQUE (operator_uid, logical_key, payload_sha256),
UNIQUE (operator_uid, import_record_id),
FOREIGN KEY (operator_uid, first_batch_id) REFERENCES fm.import_batch (operator_uid, import_batch_id),
FOREIGN KEY (operator_uid, last_seen_batch_id) REFERENCES fm.import_batch (operator_uid, import_batch_id)
); );
-- Identity -------------------------------------------------------------------------------- -- Identity: asset kinds and assets, places and holders (PD-18) --------------------------------
CREATE TABLE fm.site ( CREATE TABLE fm.asset_kind ( -- what an asset does with fuel (PD-18); global, not per customer
site_uid text PRIMARY KEY CHECK (site_uid ~ '^SITE-[0-9]{4}$'), kind text PRIMARY KEY,
name text, name_on_plate text, fuel_role text CHECK (fuel_role IN ('container','consumer','transfer','sensor'))
zone text NOT NULL, province text NOT NULL, district text, -- NULL: carries other assets and holds no fuel itself
timezone text NOT NULL DEFAULT 'Asia/Kabul',
import_record_id bigint NOT NULL REFERENCES fm.import_record, -- full site.yaml payload
null_reasons jsonb NOT NULL DEFAULT '{}'
); );
CREATE TABLE fm.site_location ( -- coordinates are claims; the map uses only an accepted one INSERT INTO fm.asset_kind (kind, fuel_role) VALUES
('generator','consumer'), ('engine','consumer'), ('tank','container'), ('compartment','container'),
('vehicle_tank','container'), ('pump','transfer'), ('vehicle',NULL);
CREATE TABLE fm.asset (
asset_uid text PRIMARY KEY CHECK (asset_uid ~ '^AST-[A-Z0-9-]+$'),
operator_uid text NOT NULL REFERENCES fm.operator,
kind text NOT NULL REFERENCES fm.asset_kind,
make text, model text, serial text, year_of_manufacture smallint,
import_record_id bigint NOT NULL,
null_reasons jsonb NOT NULL DEFAULT '{}',
UNIQUE (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id)
);
CREATE TABLE fm.place ( -- fixed geography; its coordinates are claims (place_location)
place_uid text PRIMARY KEY CHECK (place_uid ~ '^PLC-[0-9]{4,8}$'),
operator_uid text NOT NULL REFERENCES fm.operator,
role text NOT NULL CHECK (role IN ('tower_site','depot','yard','fuel_station')),
zone text NOT NULL, province text NOT NULL, district text,
timezone text, -- NULL: the operator's
import_record_id bigint NOT NULL,
null_reasons jsonb NOT NULL DEFAULT '{}',
UNIQUE (operator_uid, place_uid),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id)
);
CREATE TABLE fm.holder ( -- what assets are in or on (PD-18). For a telecom site: one
-- fixed installation at one place. Ids are opaque and permanent
holder_uid text PRIMARY KEY CHECK (holder_uid ~ '^[A-Z]{2,4}-[0-9]{4,7}$'),
operator_uid text NOT NULL REFERENCES fm.operator,
kind text NOT NULL CHECK (kind IN ('fixed_installation','vehicle','vessel','trailer')),
name text, name_on_plate text,
asset_uid text, -- a vehicle is both an asset and a holder
import_record_id bigint NOT NULL,
null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK ((kind = 'vehicle') = (asset_uid IS NOT NULL)),
UNIQUE (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id)
);
CREATE TABLE fm.holder_place ( -- where a fixed holder stands; a vehicle's position is telemetry
holder_uid text NOT NULL,
operator_uid text NOT NULL REFERENCES fm.operator,
place_uid text NOT NULL,
valid_from date, valid_to date, -- NULL from = unknown start; NULL to = still there
null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK (valid_from IS NOT NULL OR null_reasons ? 'valid_from'),
EXCLUDE USING gist (holder_uid WITH =,
daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, place_uid) REFERENCES fm.place (operator_uid, place_uid)
);
CREATE TABLE fm.place_location ( -- coordinates are claims; the map uses only an accepted one
location_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, location_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, -- 'site-yaml:SITE-0001:coordinates' source_key text NOT NULL, -- 'site-yaml:SITE-0001:coordinates'
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), -- site, lat, lon, precision, basis assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), -- operator, place, lat, lon, precision, basis
site_uid text NOT NULL REFERENCES fm.site, place_uid text NOT NULL,
latitude numeric(9,6) NOT NULL CHECK (latitude BETWEEN -90 AND 90), latitude numeric(9,6) NOT NULL CHECK (latitude BETWEEN -90 AND 90),
longitude numeric(9,6) NOT NULL CHECK (longitude BETWEEN -180 AND 180), longitude numeric(9,6) NOT NULL CHECK (longitude BETWEEN -180 AND 180),
precision_m integer, -- e.g. 110 where the source rounds to 0.001 deg precision_m integer, -- e.g. 110 where the source rounds to 0.001 deg
@@ -71,91 +141,111 @@ CREATE TABLE fm.site_location ( -- coordinates are claims; the
source_note text NOT NULL, -- 'operator site plate photo <catalog key>' source_note text NOT NULL, -- 'operator site plate photo <catalog key>'
acceptance text NOT NULL DEFAULT 'pending' CHECK (acceptance IN ('pending','accepted','rejected','superseded','retracted')), acceptance text NOT NULL DEFAULT 'pending' CHECK (acceptance IN ('pending','accepted','rejected','superseded','retracted')),
accepted_on date, accepted_by_role text, acceptance_ref text, -- copied from the matching fm.review row accepted_on date, accepted_by_role text, acceptance_ref text, -- copied from the matching fm.review row
superseded_by bigint REFERENCES fm.site_location, superseded_by bigint,
import_record_id bigint NOT NULL REFERENCES fm.import_record, import_record_id bigint NOT NULL,
CHECK (acceptance <> 'accepted' OR (accepted_on IS NOT NULL AND accepted_by_role IS NOT NULL)), CHECK (acceptance <> 'accepted' OR (accepted_on IS NOT NULL AND accepted_by_role IS NOT NULL)),
CHECK (acceptance <> 'superseded' OR superseded_by IS NOT NULL) CHECK (acceptance <> 'superseded' OR superseded_by IS NOT NULL),
UNIQUE (operator_uid, location_id),
FOREIGN KEY (operator_uid, place_uid) REFERENCES fm.place (operator_uid, place_uid),
FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.place_location (operator_uid, location_id),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id)
); );
CREATE UNIQUE INDEX site_location_live_key ON fm.site_location (source_key) CREATE UNIQUE INDEX place_location_live_key ON fm.place_location (operator_uid, source_key)
WHERE acceptance IN ('pending','accepted','rejected'); WHERE acceptance IN ('pending','accepted','rejected');
CREATE UNIQUE INDEX site_location_one_accepted ON fm.site_location (site_uid) WHERE acceptance = 'accepted'; CREATE UNIQUE INDEX place_location_one_accepted ON fm.place_location (place_uid) WHERE acceptance = 'accepted';
CREATE TABLE fm.site_alias ( CREATE TABLE fm.holder_alias (
site_alias_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, holder_alias_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
site_uid text NOT NULL REFERENCES fm.site, operator_uid text NOT NULL REFERENCES fm.operator,
holder_uid text NOT NULL,
label text NOT NULL, issuer text NOT NULL, label text NOT NULL, issuer text NOT NULL,
valid_from date, valid_to date, -- NULL from = unknown start; NULL to = still valid valid_from date, valid_to date, -- NULL from = unknown start; NULL to = still valid
is_primary boolean NOT NULL DEFAULT false, is_primary boolean NOT NULL DEFAULT false,
null_reasons jsonb NOT NULL DEFAULT '{}', null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK (valid_from IS NOT NULL OR null_reasons ? 'valid_from'), CHECK (valid_from IS NOT NULL OR null_reasons ? 'valid_from'),
EXCLUDE USING gist (issuer WITH =, label WITH =, EXCLUDE USING gist (operator_uid WITH =, issuer WITH =, label WITH =,
daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&), daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&),
EXCLUDE USING gist (site_uid WITH =, issuer WITH =, EXCLUDE USING gist (holder_uid WITH =, issuer WITH =,
daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&) WHERE (is_primary) daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&) WHERE (is_primary),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid)
); );
CREATE TABLE fm.pilot_participation ( CREATE TABLE fm.participation (
site_uid text NOT NULL REFERENCES fm.site, holder_uid text NOT NULL,
programme text NOT NULL DEFAULT 'ATOMA OFMS pilot', operator_uid text NOT NULL REFERENCES fm.operator,
programme text NOT NULL,
status text NOT NULL CHECK (status IN ('active','reference','candidate','withdrawn')), status text NOT NULL CHECK (status IN ('active','reference','candidate','withdrawn')),
valid_from date NOT NULL, valid_to date, valid_from date NOT NULL, valid_to date,
decision_ref text, note text, decision_ref text, note text,
EXCLUDE USING gist (site_uid WITH =, programme WITH =, daterange(valid_from, valid_to, '[)') WITH &&) EXCLUDE USING gist (holder_uid WITH =, programme WITH =, daterange(valid_from, valid_to, '[)') WITH &&),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid)
); );
CREATE TABLE fm.asset ( CREATE TABLE fm.asset_placement ( -- in or on a holder, or at a place: exactly one (PD-18)
asset_uid text PRIMARY KEY CHECK (asset_uid ~ '^AST-[A-Z0-9-]+$'),
kind text NOT NULL CHECK (kind IN ('generator','tank')),
make text, model text, serial text, year_of_manufacture smallint,
import_record_id bigint NOT NULL REFERENCES fm.import_record,
null_reasons jsonb NOT NULL DEFAULT '{}'
);
CREATE TABLE fm.asset_placement (
placement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, placement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
asset_uid text NOT NULL REFERENCES fm.asset, operator_uid text NOT NULL REFERENCES fm.operator,
site_uid text NOT NULL REFERENCES fm.site, asset_uid text NOT NULL,
holder_uid text,
place_uid text,
slot_label text NOT NULL, -- 'GEN-01': a position label, not an identity slot_label text NOT NULL, -- 'GEN-01': a position label, not an identity
occupied_from date, occupied_to date, -- NULL from = before any record (conservative) occupied_from date, occupied_to date, -- NULL from = before any record (conservative)
earliest_evidence_on date, earliest_evidence_on date,
installed_on date, installed_on date,
null_reasons jsonb NOT NULL DEFAULT '{}', null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK (num_nonnulls(holder_uid, place_uid) = 1),
CHECK (installed_on IS NOT NULL OR null_reasons ? 'installed_on'), CHECK (installed_on IS NOT NULL OR null_reasons ? 'installed_on'),
EXCLUDE USING gist (asset_uid WITH =, EXCLUDE USING gist (asset_uid WITH =,
daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&), daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&),
EXCLUDE USING gist (site_uid WITH =, slot_label WITH =, EXCLUDE USING gist (holder_uid WITH =, slot_label WITH =,
daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&) daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&) WHERE (holder_uid IS NOT NULL),
EXCLUDE USING gist (place_uid WITH =, slot_label WITH =,
daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&) WHERE (place_uid IS NOT NULL),
FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, place_uid) REFERENCES fm.place (operator_uid, place_uid)
); );
CREATE TABLE fm.runtime_meter ( CREATE TABLE fm.runtime_meter (
meter_id text PRIMARY KEY, -- ours, stable: 'MTR-SITE-0001-GENSET1' meter_id text PRIMARY KEY, -- ours, stable: 'MTR-SITE-0001-GENSET1'
site_uid text NOT NULL REFERENCES fm.site, operator_uid text NOT NULL REFERENCES fm.operator,
holder_uid text NOT NULL,
source_label text NOT NULL, -- as written: 'Genset 1', 'R.H' source_label text NOT NULL, -- as written: 'Genset 1', 'R.H'
asset_uid text REFERENCES fm.asset, asset_uid text,
mapping_status text NOT NULL CHECK (mapping_status IN ('confirmed','proposed','unmapped')), mapping_status text NOT NULL CHECK (mapping_status IN ('confirmed','proposed','unmapped')),
mapping_basis text, mapping_basis text,
UNIQUE (site_uid, source_label), UNIQUE (holder_uid, source_label),
CHECK ((mapping_status = 'unmapped') = (asset_uid IS NULL)) CHECK ((mapping_status = 'unmapped') = (asset_uid IS NULL)),
UNIQUE (operator_uid, meter_id),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid)
); );
CREATE TABLE fm.meter_epoch ( CREATE TABLE fm.meter_epoch (
epoch_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, epoch_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
meter_id text NOT NULL REFERENCES fm.runtime_meter, operator_uid text NOT NULL REFERENCES fm.operator,
meter_id text NOT NULL,
epoch_no smallint NOT NULL, epoch_no smallint NOT NULL,
starts_on date, -- NULL: began before the first reading held starts_on date, -- NULL: began before the first reading held
start_reason text NOT NULL CHECK (start_reason IN ('first_seen','display_replaced','controller_replaced','reset','set_replaced')), start_reason text NOT NULL CHECK (start_reason IN ('first_seen','display_replaced','controller_replaced','reset','set_replaced')),
continuity text NOT NULL CHECK (continuity IN ('confirmed','assumed','uncertain')), continuity text NOT NULL CHECK (continuity IN ('confirmed','assumed','uncertain')),
rollover_at numeric(9,1), rollover_at numeric(9,1),
source_sha256 text REFERENCES fm.source_document, note text, source_sha256 text, note text,
UNIQUE (meter_id, epoch_no) UNIQUE (meter_id, epoch_no),
UNIQUE (operator_uid, epoch_id),
FOREIGN KEY (operator_uid, meter_id) REFERENCES fm.runtime_meter (operator_uid, meter_id),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256)
); );
-- Operations (claims) --------------------------------------------------------------------- -- Operations (claims) ---------------------------------------------------------------------
CREATE TABLE fm.visit ( CREATE TABLE fm.visit (
visit_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, visit_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
visit_key text NOT NULL UNIQUE, -- 'SITE-0001:2026-01-31:1' visit_key text NOT NULL UNIQUE, -- 'SITE-0001:2026-01-31:1'
site_uid text NOT NULL REFERENCES fm.site, holder_uid text NOT NULL,
visit_on date NOT NULL, -- site-local calendar date visit_on date NOT NULL, -- holder-local calendar date
seq_in_day smallint NOT NULL DEFAULT 1, seq_in_day smallint NOT NULL DEFAULT 1,
time_in_local time, time_out_local time, -- as written; NULL when the source has a date only time_in_local time, time_out_local time, -- as written; NULL when the source has a date only
kind text NOT NULL CHECK (kind IN ('delivery','stock_check','maintenance','survey')), kind text NOT NULL CHECK (kind IN ('delivery','stock_check','maintenance','survey')),
-- descriptive only: views use fm.v_visit_live and the live claims -- descriptive only: views use fm.v_visit_live and the live claims
UNIQUE (site_uid, visit_on, seq_in_day), UNIQUE (holder_uid, visit_on, seq_in_day),
UNIQUE (visit_id, site_uid) -- target of site-checked composite foreign keys UNIQUE (operator_uid, visit_id, holder_uid), -- target of holder-checked composite foreign keys
UNIQUE (operator_uid, visit_id),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid)
); );
-- A visit is never deleted. Once all its claims are superseded or retracted it stays for provenance -- A visit is never deleted. Once all its claims are superseded or retracted it stays for provenance
-- only, and drops out of every analytical view (fm.v_visit_live). -- only, and drops out of every analytical view (fm.v_visit_live).
@@ -164,42 +254,52 @@ CREATE TABLE fm.visit (
-- superseded_by, review_note, null_reasons -- superseded_by, review_note, null_reasons
CREATE TABLE fm.fuel_movement ( CREATE TABLE fm.fuel_movement (
movement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, movement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, -- '<family>:delivery:SITE-0001:2026-01-31:1' source_key text NOT NULL, -- '<family>:delivery:SITE-0001:2026-01-31:1'
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
event_key text NOT NULL, -- the physical movement: 'SITE-0001:2026-01-31:1:delivery:1'; event_key text NOT NULL, -- the physical movement: 'SITE-0001:2026-01-31:1:delivery:1';
-- claims sharing it are alternative reports of ONE movement -- claims sharing it are alternative reports of ONE movement
visit_id bigint NOT NULL REFERENCES fm.visit, visit_id bigint NOT NULL,
site_uid text NOT NULL REFERENCES fm.site, holder_uid text NOT NULL,
kind text NOT NULL CHECK (kind IN ('delivery','external_in','internal_transfer', kind text NOT NULL CHECK (kind IN ('delivery','external_in','internal_transfer',
'withdrawal','drain','spill','loss_reported')), 'withdrawal','drain','spill','loss_reported')),
from_tank_uid text REFERENCES fm.asset, to_tank_uid text REFERENCES fm.asset, from_tank_uid text, to_tank_uid text,
source_tank_label text, source_tank_label text,
litres numeric(8,1) CHECK (litres >= 0), litres numeric(8,1) CHECK (litres >= 0),
litres_state text NOT NULL CHECK (litres_state IN ('stated','converted_from_level','converted_from_mass','not_recorded','illegible')), litres_state text NOT NULL CHECK (litres_state IN ('stated','converted_from_level','converted_from_mass','not_recorded','illegible')),
density_kg_per_l numeric(5,3), density_kg_per_l numeric(5,3),
unit_price numeric(8,2), price_currency text, unit_price numeric(8,2), price_currency text,
source_sha256 text NOT NULL REFERENCES fm.source_document, source_sha256 text NOT NULL,
record_ref text NOT NULL, -- 'form <no>' | '<sheet>!J20' | 'logbook p2 r1' record_ref text NOT NULL, -- 'form <no>' | '<sheet>!J20' | 'logbook p2 r1'
import_record_id bigint NOT NULL REFERENCES fm.import_record, import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint REFERENCES fm.fuel_movement, superseded_by bigint,
review_note text, review_note text,
null_reasons jsonb NOT NULL DEFAULT '{}', null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK ((litres IS NULL) = (litres_state IN ('not_recorded','illegible'))), CHECK ((litres IS NULL) = (litres_state IN ('not_recorded','illegible'))),
CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL),
CHECK (kind <> 'internal_transfer' OR (from_tank_uid IS NOT NULL AND to_tank_uid IS NOT NULL)) CHECK (kind <> 'internal_transfer' OR (from_tank_uid IS NOT NULL AND to_tank_uid IS NOT NULL)),
UNIQUE (operator_uid, movement_id),
FOREIGN KEY (operator_uid, visit_id) REFERENCES fm.visit (operator_uid, visit_id),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, from_tank_uid) REFERENCES fm.asset (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, to_tank_uid) REFERENCES fm.asset (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id),
FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.fuel_movement (operator_uid, movement_id)
); );
CREATE UNIQUE INDEX fuel_movement_live_key ON fm.fuel_movement (source_key) CREATE UNIQUE INDEX fuel_movement_live_key ON fm.fuel_movement (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed'); WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE INDEX ON fm.fuel_movement (visit_id, event_key); CREATE INDEX ON fm.fuel_movement (visit_id, event_key);
CREATE TABLE fm.stock_observation ( CREATE TABLE fm.stock_observation (
stock_obs_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, stock_obs_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, source_key text NOT NULL,
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
visit_id bigint NOT NULL REFERENCES fm.visit, visit_id bigint NOT NULL,
site_uid text NOT NULL REFERENCES fm.site, holder_uid text NOT NULL,
scope text NOT NULL CHECK (scope IN ('site_total','tank')), scope text NOT NULL CHECK (scope IN ('site_total','tank')),
tank_uid text REFERENCES fm.asset, -- only when the source identifies the tank tank_uid text, -- only when the source identifies the tank
source_tank_label text, -- 'T1' as written, even when unmapped source_tank_label text, -- 'T1' as written, even when unmapped
phase text NOT NULL CHECK (phase IN ('before_delivery','after_delivery','standalone')), phase text NOT NULL CHECK (phase IN ('before_delivery','after_delivery','standalone')),
level_cm numeric(6,1), level_cm numeric(6,1),
@@ -208,27 +308,35 @@ CREATE TABLE fm.stock_observation (
'stated_total','level_only','not_recorded','illegible','placeholder_zero')), 'stated_total','level_only','not_recorded','illegible','placeholder_zero')),
resolution_l numeric(6,1), -- ±1 cm of dip at this level, from tank geometry resolution_l numeric(6,1), -- ±1 cm of dip at this level, from tank geometry
resolution_basis text, resolution_basis text,
source_sha256 text NOT NULL REFERENCES fm.source_document, source_sha256 text NOT NULL,
record_ref text NOT NULL, record_ref text NOT NULL,
import_record_id bigint NOT NULL REFERENCES fm.import_record, import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint REFERENCES fm.stock_observation, superseded_by bigint,
review_note text, review_note text,
null_reasons jsonb NOT NULL DEFAULT '{}', null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK (scope = 'site_total' OR tank_uid IS NOT NULL OR source_tank_label IS NOT NULL), CHECK (scope = 'site_total' OR tank_uid IS NOT NULL OR source_tank_label IS NOT NULL),
CHECK ((litres IS NULL) = (litres_basis IN ('level_only','not_recorded','illegible','placeholder_zero'))), CHECK ((litres IS NULL) = (litres_basis IN ('level_only','not_recorded','illegible','placeholder_zero'))),
CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL) CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL),
UNIQUE (operator_uid, stock_obs_id),
FOREIGN KEY (operator_uid, visit_id) REFERENCES fm.visit (operator_uid, visit_id),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, tank_uid) REFERENCES fm.asset (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id),
FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.stock_observation (operator_uid, stock_obs_id)
); );
CREATE UNIQUE INDEX stock_obs_live_key ON fm.stock_observation (source_key) CREATE UNIQUE INDEX stock_obs_live_key ON fm.stock_observation (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed'); WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE INDEX ON fm.stock_observation (visit_id, phase); CREATE INDEX ON fm.stock_observation (visit_id, phase);
CREATE TABLE fm.hour_meter_reading ( CREATE TABLE fm.hour_meter_reading (
reading_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, reading_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, source_key text NOT NULL,
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
visit_id bigint NOT NULL REFERENCES fm.visit, visit_id bigint NOT NULL,
meter_id text NOT NULL REFERENCES fm.runtime_meter, meter_id text NOT NULL,
epoch_id bigint REFERENCES fm.meter_epoch, -- NULL = not yet judged; excluded from runtime epoch_id bigint, -- NULL = not yet judged; excluded from runtime
source_field text NOT NULL CHECK (source_field IN ('current','previous','logbook_rh','display_photo')), source_field text NOT NULL CHECK (source_field IN ('current','previous','logbook_rh','display_photo')),
date_basis text NOT NULL CHECK (date_basis IN ('stated','inferred_previous_visit','capture_date_unknown')), date_basis text NOT NULL CHECK (date_basis IN ('stated','inferred_previous_visit','capture_date_unknown')),
hours numeric(9,1), hours numeric(9,1),
@@ -236,84 +344,109 @@ CREATE TABLE fm.hour_meter_reading (
reading_state text NOT NULL CHECK (reading_state IN ('clear','best_reading','ambiguous','illegible','crossed_out','not_recorded')), reading_state text NOT NULL CHECK (reading_state IN ('clear','best_reading','ambiguous','illegible','crossed_out','not_recorded')),
alternatives text, -- competing readings of one illegible value, '|'-separated alternatives text, -- competing readings of one illegible value, '|'-separated
transcribed_by text NOT NULL CHECK (transcribed_by IN ('source_operator','our_reading')), transcribed_by text NOT NULL CHECK (transcribed_by IN ('source_operator','our_reading')),
source_sha256 text NOT NULL REFERENCES fm.source_document, source_sha256 text NOT NULL,
record_ref text NOT NULL, record_ref text NOT NULL,
import_record_id bigint NOT NULL REFERENCES fm.import_record, import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint REFERENCES fm.hour_meter_reading, superseded_by bigint,
review_note text, review_note text,
null_reasons jsonb NOT NULL DEFAULT '{}', null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK ((hours IS NULL) = (reading_state IN ('ambiguous','illegible','crossed_out','not_recorded'))), CHECK ((hours IS NULL) = (reading_state IN ('ambiguous','illegible','crossed_out','not_recorded'))),
CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL) CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL),
UNIQUE (operator_uid, reading_id),
FOREIGN KEY (operator_uid, visit_id) REFERENCES fm.visit (operator_uid, visit_id),
FOREIGN KEY (operator_uid, meter_id) REFERENCES fm.runtime_meter (operator_uid, meter_id),
FOREIGN KEY (operator_uid, epoch_id) REFERENCES fm.meter_epoch (operator_uid, epoch_id),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id),
FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.hour_meter_reading (operator_uid, reading_id)
); );
CREATE UNIQUE INDEX hour_reading_live_key ON fm.hour_meter_reading (source_key) CREATE UNIQUE INDEX hour_reading_live_key ON fm.hour_meter_reading (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed'); WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE INDEX ON fm.hour_meter_reading (visit_id, meter_id); CREATE INDEX ON fm.hour_meter_reading (visit_id, meter_id);
CREATE TABLE fm.runtime_interval ( -- future: controller / ATS logs, telemetry CREATE TABLE fm.runtime_interval ( -- future: controller / ATS logs, telemetry
runtime_interval_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, runtime_interval_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, -- live-unique, as for every claim source_key text NOT NULL, -- live-unique, as for every claim
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
site_uid text NOT NULL REFERENCES fm.site, holder_uid text NOT NULL,
asset_uid text REFERENCES fm.asset, asset_uid text,
scope text NOT NULL CHECK (scope IN ('generator','site_any_generator')), scope text NOT NULL CHECK (scope IN ('generator','site_any_generator')),
started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL CHECK (ended_at > started_at), started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL CHECK (ended_at > started_at),
source_kind text NOT NULL CHECK (source_kind IN ('controller_event_log','ats_log','gateway_telemetry','manual_log')), source_kind text NOT NULL CHECK (source_kind IN ('controller_event_log','ats_log','gateway_telemetry','manual_log')),
source_sha256 text REFERENCES fm.source_document, source_sha256 text,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint REFERENCES fm.runtime_interval, superseded_by bigint,
CHECK ((scope = 'generator') = (asset_uid IS NOT NULL)), CHECK ((scope = 'generator') = (asset_uid IS NOT NULL)),
CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL),
EXCLUDE USING gist (site_uid WITH =, scope WITH =, (coalesce(asset_uid,'')) WITH =, source_kind WITH =, EXCLUDE USING gist (holder_uid WITH =, scope WITH =, (coalesce(asset_uid,'')) WITH =, source_kind WITH =,
tstzrange(started_at, ended_at) WITH &&) WHERE (claim_status IN ('asserted','reviewed')) tstzrange(started_at, ended_at) WITH &&) WHERE (claim_status IN ('asserted','reviewed')),
UNIQUE (operator_uid, runtime_interval_id),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256),
FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.runtime_interval (operator_uid, runtime_interval_id)
); );
CREATE UNIQUE INDEX runtime_interval_live_key ON fm.runtime_interval (source_key) CREATE UNIQUE INDEX runtime_interval_live_key ON fm.runtime_interval (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed'); WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE TABLE fm.engine_fuel_total ( -- future: flow-meter net fuel per period CREATE TABLE fm.engine_fuel_total ( -- future: flow-meter net fuel per period
total_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, total_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, -- live-unique, as for every claim source_key text NOT NULL, -- live-unique, as for every claim
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
asset_uid text NOT NULL REFERENCES fm.asset, asset_uid text NOT NULL,
started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL CHECK (ended_at > started_at), started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL CHECK (ended_at > started_at),
net_fuel_l numeric(10,2) NOT NULL, -- feed − return as the device reports net_fuel_l numeric(10,2) NOT NULL, -- feed − return as the device reports
engine_hours numeric(8,2), -- same device, if reported engine_hours numeric(8,2), -- same device, if reported
source_kind text NOT NULL CHECK (source_kind IN ('differential_flow_meter','single_flow_meter','controller_fuel_counter')), source_kind text NOT NULL CHECK (source_kind IN ('differential_flow_meter','single_flow_meter','controller_fuel_counter')),
source_sha256 text REFERENCES fm.source_document, source_sha256 text,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint REFERENCES fm.engine_fuel_total, superseded_by bigint,
CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL),
EXCLUDE USING gist (asset_uid WITH =, tstzrange(started_at, ended_at) WITH &&) EXCLUDE USING gist (asset_uid WITH =, tstzrange(started_at, ended_at) WITH &&)
WHERE (claim_status IN ('asserted','reviewed')) WHERE (claim_status IN ('asserted','reviewed')),
UNIQUE (operator_uid, total_id),
FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256),
FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.engine_fuel_total (operator_uid, total_id)
); );
CREATE UNIQUE INDEX engine_fuel_total_live_key ON fm.engine_fuel_total (source_key) CREATE UNIQUE INDEX engine_fuel_total_live_key ON fm.engine_fuel_total (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed'); WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE TABLE fm.electricity_bill ( CREATE TABLE fm.electricity_bill (
bill_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, bill_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, source_key text NOT NULL,
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
site_uid text NOT NULL REFERENCES fm.site, holder_uid text NOT NULL,
bill_month date NOT NULL CHECK (extract(day FROM bill_month) = 1), -- month as the source assigns it bill_month date NOT NULL CHECK (extract(day FROM bill_month) = 1), -- month as the source assigns it
period_start date, period_end date, period_start date, period_end date,
amount numeric(12,2), amount numeric(12,2),
currency text, currency_basis text CHECK (currency_basis IN ('stated','inferred')), currency text, currency_basis text CHECK (currency_basis IN ('stated','inferred')),
kwh numeric(10,1), kwh numeric(10,1),
source_sha256 text NOT NULL REFERENCES fm.source_document, source_sha256 text NOT NULL,
record_ref text NOT NULL, record_ref text NOT NULL,
import_record_id bigint NOT NULL REFERENCES fm.import_record, import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint REFERENCES fm.electricity_bill, superseded_by bigint,
null_reasons jsonb NOT NULL DEFAULT '{}', null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK (kwh IS NOT NULL OR null_reasons ? 'kwh'), CHECK (kwh IS NOT NULL OR null_reasons ? 'kwh'),
CHECK (period_start IS NOT NULL OR null_reasons ? 'period_start') CHECK (period_start IS NOT NULL OR null_reasons ? 'period_start'),
UNIQUE (operator_uid, bill_id),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id),
FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.electricity_bill (operator_uid, bill_id)
); );
CREATE UNIQUE INDEX bill_live_key ON fm.electricity_bill (source_key) CREATE UNIQUE INDEX bill_live_key ON fm.electricity_bill (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed'); WHERE claim_status IN ('asserted','reviewed','disputed');
-- Specifications, assumptions, imported calculations ------------------------------------- -- Specifications, assumptions, imported calculations -------------------------------------
CREATE TABLE fm.equipment_spec ( -- versioned like a claim: corrections supersede CREATE TABLE fm.equipment_spec ( -- versioned like a claim: corrections supersede
spec_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, spec_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, -- 'site-yaml:SITE-0001:AST-…:rated_prime_kw' source_key text NOT NULL, -- 'site-yaml:SITE-0001:AST-…:rated_prime_kw'
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
asset_uid text NOT NULL REFERENCES fm.asset, asset_uid text NOT NULL,
attribute text NOT NULL, -- rated_prime_kw, capacity_l, fuel_rate_at_load, … attribute text NOT NULL, -- rated_prime_kw, capacity_l, fuel_rate_at_load, …
load_pct numeric(5,1), load_pct numeric(5,1),
value_num numeric(12,3), value_text text, unit text, value_num numeric(12,3), value_text text, unit text,
@@ -321,22 +454,28 @@ CREATE TABLE fm.equipment_spec ( -- versioned like a claim: corre
'operator_stated','our_inference','geometry')), 'operator_stated','our_inference','geometry')),
applicability text NOT NULL DEFAULT 'confirmed' CHECK (applicability IN ('confirmed','conditional','unconfirmed')), applicability text NOT NULL DEFAULT 'confirmed' CHECK (applicability IN ('confirmed','conditional','unconfirmed')),
applicability_note text, applicability_note text,
source_sha256 text REFERENCES fm.source_document, source_sha256 text,
import_record_id bigint NOT NULL REFERENCES fm.import_record, import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint REFERENCES fm.equipment_spec, superseded_by bigint,
CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL),
CHECK ((attribute IN ('fuel_rate_at_load','specific_fuel_at_load')) = (load_pct IS NOT NULL)) CHECK ((attribute IN ('fuel_rate_at_load','specific_fuel_at_load')) = (load_pct IS NOT NULL)),
UNIQUE (operator_uid, spec_id),
FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id),
FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.equipment_spec (operator_uid, spec_id)
); );
CREATE UNIQUE INDEX equipment_spec_live_key ON fm.equipment_spec (source_key) CREATE UNIQUE INDEX equipment_spec_live_key ON fm.equipment_spec (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed'); WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE TABLE fm.assumption ( -- one row per version; a change is a new version CREATE TABLE fm.assumption ( -- one row per version; a change is a new version
assumption_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, assumption_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
code text NOT NULL UNIQUE, -- 'A-SITE-0001-GEN02-IDLE-v1' code text NOT NULL UNIQUE, -- 'A-SITE-0001-GEN02-IDLE-v1'
fingerprint text NOT NULL CHECK (fingerprint ~ '^[0-9a-f]{64}$'), -- all fields below + sorted members fingerprint text NOT NULL CHECK (fingerprint ~ '^[0-9a-f]{64}$'), -- all fields below + sorted members
created_xact xid8 NOT NULL DEFAULT pg_current_xact_id(), -- rule 9: members only in this transaction created_xact xid8 NOT NULL DEFAULT pg_current_xact_id(), -- rule 9: members only in this transaction
site_uid text NOT NULL REFERENCES fm.site, holder_uid text NOT NULL,
asset_uid text REFERENCES fm.asset, asset_uid text,
topic text NOT NULL CHECK (topic IN ('runtime_hours_per_day','generator_idle','meter_coverage', topic text NOT NULL CHECK (topic IN ('runtime_hours_per_day','generator_idle','meter_coverage',
'tank_linkage','no_unrecorded_movements','density','other')), 'tank_linkage','no_unrecorded_movements','density','other')),
-- meter_coverage: its members name the exact counters, -- meter_coverage: its members name the exact counters,
@@ -347,77 +486,97 @@ CREATE TABLE fm.assumption ( -- one row per version; a chang
derived_from_fuel boolean NOT NULL, -- true: never a runtime input to an L/h figure derived_from_fuel boolean NOT NULL, -- true: never a runtime input to an L/h figure
status text NOT NULL CHECK (status IN ('proposed','accepted','rejected','superseded')), status text NOT NULL CHECK (status IN ('proposed','accepted','rejected','superseded')),
accepted_on date, accepted_by_role text, decision_ref text, -- copied from the matching fm.review row accepted_on date, accepted_by_role text, decision_ref text, -- copied from the matching fm.review row
supersedes_id bigint UNIQUE REFERENCES fm.assumption, supersedes_id bigint UNIQUE,
rationale text NOT NULL, contradicted_by text, rationale text NOT NULL, contradicted_by text,
source_sha256 text REFERENCES fm.source_document, source_sha256 text,
CHECK (status <> 'accepted' OR (accepted_on IS NOT NULL AND accepted_by_role IS NOT NULL)), CHECK (status <> 'accepted' OR (accepted_on IS NOT NULL AND accepted_by_role IS NOT NULL)),
CONSTRAINT assumption_no_runtime_from_fuel -- rule 5 CONSTRAINT assumption_no_runtime_from_fuel -- rule 5
CHECK (NOT (derived_from_fuel AND topic = 'runtime_hours_per_day' AND status = 'accepted')), CHECK (NOT (derived_from_fuel AND topic = 'runtime_hours_per_day' AND status = 'accepted')),
CHECK (topic <> 'generator_idle' OR asset_uid IS NOT NULL), CHECK (topic <> 'generator_idle' OR asset_uid IS NOT NULL),
CHECK (topic <> 'meter_coverage' OR asset_uid IS NULL), -- its sets are in assumption_member CHECK (topic <> 'meter_coverage' OR asset_uid IS NULL), -- its sets are in assumption_member
EXCLUDE USING gist (site_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&) EXCLUDE USING gist (holder_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&)
WHERE (topic = 'meter_coverage' AND status IN ('proposed','accepted')), WHERE (topic = 'meter_coverage' AND status IN ('proposed','accepted')),
EXCLUDE USING gist (asset_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&) EXCLUDE USING gist (asset_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&)
WHERE (topic = 'generator_idle' AND status IN ('proposed','accepted')) WHERE (topic = 'generator_idle' AND status IN ('proposed','accepted')),
UNIQUE (operator_uid, assumption_id),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid),
FOREIGN KEY (operator_uid, supersedes_id) REFERENCES fm.assumption (operator_uid, assumption_id),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256)
); );
CREATE TABLE fm.assumption_member ( -- exact membership of a meter_coverage assumption CREATE TABLE fm.assumption_member ( -- exact membership of a meter_coverage assumption
assumption_id bigint NOT NULL REFERENCES fm.assumption, assumption_id bigint NOT NULL,
operator_uid text NOT NULL REFERENCES fm.operator,
member_role text NOT NULL CHECK (member_role IN ('counter','covered_generator','idle_generator')), member_role text NOT NULL CHECK (member_role IN ('counter','covered_generator','idle_generator')),
meter_id text REFERENCES fm.runtime_meter, meter_id text,
asset_uid text REFERENCES fm.asset, asset_uid text,
CHECK ((member_role = 'counter') = (meter_id IS NOT NULL)), CHECK ((member_role = 'counter') = (meter_id IS NOT NULL)),
CHECK ((member_role = 'counter') = (asset_uid IS NULL)) CHECK ((member_role = 'counter') = (asset_uid IS NULL)),
FOREIGN KEY (operator_uid, assumption_id) REFERENCES fm.assumption (operator_uid, assumption_id),
FOREIGN KEY (operator_uid, meter_id) REFERENCES fm.runtime_meter (operator_uid, meter_id),
FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid)
); );
CREATE UNIQUE INDEX assumption_member_meter ON fm.assumption_member (assumption_id, meter_id) WHERE meter_id IS NOT NULL; CREATE UNIQUE INDEX assumption_member_meter ON fm.assumption_member (assumption_id, meter_id) WHERE meter_id IS NOT NULL;
CREATE UNIQUE INDEX assumption_member_asset ON fm.assumption_member (assumption_id, asset_uid) WHERE asset_uid IS NOT NULL; CREATE UNIQUE INDEX assumption_member_asset ON fm.assumption_member (assumption_id, asset_uid) WHERE asset_uid IS NOT NULL;
-- a set is either covered or idle within one assumption -- a set is either covered or idle within one assumption
CREATE TABLE fm.imported_calculation ( -- versioned like a claim: a revised formula supersedes CREATE TABLE fm.imported_calculation ( -- versioned like a claim: a revised formula supersedes
calc_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, calc_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, -- '<family>:calc:SITE-0001:<label>' source_key text NOT NULL, -- '<family>:calc:SITE-0001:<label>'
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
source_sha256 text NOT NULL REFERENCES fm.source_document, source_sha256 text NOT NULL,
cell_ref text NOT NULL, -- '<sheet>!K2' | '<survey item>' cell_ref text NOT NULL, -- '<sheet>!K2' | '<survey item>'
site_uid text REFERENCES fm.site, holder_uid text,
source_label text NOT NULL, -- as written in the source source_label text NOT NULL, -- as written in the source
formula text, cached_value numeric(14,6), unit_as_labelled text, formula text, cached_value numeric(14,6), unit_as_labelled text,
our_reading text NOT NULL, -- what it actually computes our_reading text NOT NULL, -- what it actually computes
baseline_use text NOT NULL CHECK (baseline_use IN ('excluded','context_only')), baseline_use text NOT NULL CHECK (baseline_use IN ('excluded','context_only')),
exclusion_reason text, exclusion_reason text,
import_record_id bigint NOT NULL REFERENCES fm.import_record, import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint REFERENCES fm.imported_calculation, superseded_by bigint,
CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL),
CHECK (baseline_use <> 'excluded' OR exclusion_reason IS NOT NULL) CHECK (baseline_use <> 'excluded' OR exclusion_reason IS NOT NULL),
UNIQUE (operator_uid, calc_id),
FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id),
FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.imported_calculation (operator_uid, calc_id)
); );
CREATE UNIQUE INDEX imported_calc_live_key ON fm.imported_calculation (source_key) CREATE UNIQUE INDEX imported_calc_live_key ON fm.imported_calculation (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed'); WHERE claim_status IN ('asserted','reviewed','disputed');
-- Reviews --------------------------------------------------------------------------------- -- Reviews ---------------------------------------------------------------------------------
CREATE TABLE fm.review ( -- one applied entry of the corpus review manifest CREATE TABLE fm.review ( -- one applied entry of the corpus review manifest
review_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, review_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_key text NOT NULL UNIQUE, -- the manifest entry's own id operator_uid text NOT NULL REFERENCES fm.operator,
entry_key text NOT NULL, -- the manifest entry's own id, per customer
subject_kind text NOT NULL CHECK (subject_kind IN ('claim','location','assumption')), subject_kind text NOT NULL CHECK (subject_kind IN ('claim','location','assumption')),
subject_key text NOT NULL, -- source_key, or the assumption code subject_key text NOT NULL, -- source_key, or the assumption code
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), -- what was actually reviewed assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), -- what was actually reviewed
checked_against_sha256 text REFERENCES fm.source_document, -- the file version compared with checked_against_sha256 text, -- the file version compared with
verdict text NOT NULL CHECK (verdict IN ('reviewed','disputed','accepted','rejected')), verdict text NOT NULL CHECK (verdict IN ('reviewed','disputed','accepted','rejected')),
reviewed_on date NOT NULL, reviewer_role text NOT NULL, decision_ref text, note text, reviewed_on date NOT NULL, reviewer_role text NOT NULL, decision_ref text, note text,
import_batch_id bigint NOT NULL REFERENCES fm.import_batch, import_batch_id bigint NOT NULL,
CHECK ((subject_kind = 'claim') = (verdict IN ('reviewed','disputed'))) CHECK ((subject_kind = 'claim') = (verdict IN ('reviewed','disputed'))),
UNIQUE (operator_uid, entry_key),
FOREIGN KEY (operator_uid, checked_against_sha256) REFERENCES fm.source_document (operator_uid, sha256),
FOREIGN KEY (operator_uid, import_batch_id) REFERENCES fm.import_batch (operator_uid, import_batch_id)
); );
CREATE INDEX ON fm.review (subject_key, assertion_sha256); CREATE INDEX ON fm.review (operator_uid, subject_key, assertion_sha256);
-- Baseline -------------------------------------------------------------------------------- -- Baseline --------------------------------------------------------------------------------
CREATE TABLE fm.baseline_version ( CREATE TABLE fm.baseline_version (
baseline_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, baseline_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
code text NOT NULL UNIQUE, -- 'BL-SITE-0001-APGH-v1' code text NOT NULL UNIQUE, -- 'BL-SITE-0001-APGH-v1'
site_uid text NOT NULL REFERENCES fm.site, holder_uid text NOT NULL,
metric text NOT NULL CHECK (metric IN ('measured_generator_l_per_h','apparent_l_per_generator_h', metric text NOT NULL CHECK (metric IN ('measured_generator_l_per_h','apparent_l_per_generator_h',
'apparent_l_per_set_h','apparent_l_per_site_dg_h','modelled_l_per_h','fuel_l_per_calendar_day')), 'apparent_l_per_set_h','apparent_l_per_site_dg_h','modelled_l_per_h','fuel_l_per_calendar_day')),
population text NOT NULL, -- human-readable: 'GEN-01 (AST-XXX-GEN-0001)' population text NOT NULL, -- human-readable: 'GEN-01 (AST-XXX-GEN-0001)'
population_key text NOT NULL, -- sorted asset uids, 'meter:<id>' for counters not tied population_key text NOT NULL, -- sorted asset uids, 'meter:<id>' for counters not tied
-- to a set plus 'covers:<asset uid>' for the sets they -- to a set plus 'covers:<asset uid>' for the sets they
-- cover, or 'stock:<site_uid>' for fuel_l_per_calendar_day -- cover, or 'stock:<holder_uid>' for fuel_l_per_calendar_day
window_start date NOT NULL, window_end date NOT NULL CHECK (window_end > window_start), window_start date NOT NULL, window_end date NOT NULL CHECK (window_end > window_start),
policy_version text NOT NULL, -- 'BP-1' policy_version text NOT NULL, -- 'BP-1'
meets_policy boolean NOT NULL DEFAULT false,-- set by `baseline propose` from the inputs (BP-1 test) meets_policy boolean NOT NULL DEFAULT false,-- set by `baseline propose` from the inputs (BP-1 test)
@@ -426,19 +585,23 @@ CREATE TABLE fm.baseline_version (
fuel_l numeric(10,1), runtime_h numeric(10,1), matched_days integer, fuel_l numeric(10,1), runtime_h numeric(10,1), matched_days integer,
conditions text, -- assumption codes the value depends on conditions text, -- assumption codes the value depends on
evidence_grade text CHECK (evidence_grade IN ('G1','G2')), evidence_grade text CHECK (evidence_grade IN ('G1','G2')),
atoma_tier text CHECK (atoma_tier IN ('A','B','C')), -- descriptive only (Baseline Verification Plan) operator_tier text CHECK (operator_tier IN ('A','B','C')), -- descriptive only (Baseline Verification Plan)
season_note text, season_note text,
status text NOT NULL CHECK (status IN ('draft','proposed','approved','superseded','rejected')), status text NOT NULL CHECK (status IN ('draft','proposed','approved','superseded','rejected')),
computed_at timestamptz NOT NULL, computed_by_role text NOT NULL, computed_at timestamptz NOT NULL, computed_by_role text NOT NULL,
approved_on date, approved_by_role text, decision_ref text, approved_on date, approved_by_role text, decision_ref text,
supersedes_id bigint REFERENCES fm.baseline_version, supersedes_id bigint,
UNIQUE (baseline_id, site_uid), UNIQUE (operator_uid, baseline_id, holder_uid),
CHECK (status <> 'approved' OR (approved_on IS NOT NULL AND decision_ref IS NOT NULL)) CHECK (status <> 'approved' OR (approved_on IS NOT NULL AND decision_ref IS NOT NULL)),
UNIQUE (operator_uid, baseline_id),
FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid),
FOREIGN KEY (operator_uid, supersedes_id) REFERENCES fm.baseline_version (operator_uid, baseline_id)
); );
CREATE UNIQUE INDEX baseline_one_approved ON fm.baseline_version (site_uid, metric) WHERE status = 'approved'; CREATE UNIQUE INDEX baseline_one_approved ON fm.baseline_version (holder_uid, metric) WHERE status = 'approved';
CREATE TABLE fm.baseline_input ( -- an interval is identified by its two endpoint visits CREATE TABLE fm.baseline_input ( -- an interval is identified by its two endpoint visits
baseline_id bigint NOT NULL, baseline_id bigint NOT NULL,
site_uid text NOT NULL, operator_uid text NOT NULL REFERENCES fm.operator,
holder_uid text NOT NULL,
open_visit_id bigint NOT NULL, open_visit_id bigint NOT NULL,
close_visit_id bigint NOT NULL, close_visit_id bigint NOT NULL,
start_on date NOT NULL, end_on date NOT NULL CHECK (end_on >= start_on), start_on date NOT NULL, end_on date NOT NULL CHECK (end_on >= start_on),
@@ -448,9 +611,9 @@ CREATE TABLE fm.baseline_input ( -- an interval is identified by
dependencies jsonb NOT NULL, -- {"stock": [...], "runtime": [...]} from fm.v_interval_dependency dependencies jsonb NOT NULL, -- {"stock": [...], "runtime": [...]} from fm.v_interval_dependency
stock_dep_sha256 text NOT NULL, runtime_dep_sha256 text, -- their hashes; runtime NULL for L/day versions stock_dep_sha256 text NOT NULL, runtime_dep_sha256 text, -- their hashes; runtime NULL for L/day versions
PRIMARY KEY (baseline_id, open_visit_id, close_visit_id), PRIMARY KEY (baseline_id, open_visit_id, close_visit_id),
FOREIGN KEY (baseline_id, site_uid) REFERENCES fm.baseline_version (baseline_id, site_uid), FOREIGN KEY (operator_uid, baseline_id, holder_uid) REFERENCES fm.baseline_version (operator_uid, baseline_id, holder_uid),
FOREIGN KEY (open_visit_id, site_uid) REFERENCES fm.visit (visit_id, site_uid), -- both endpoints belong FOREIGN KEY (operator_uid, open_visit_id, holder_uid) REFERENCES fm.visit (operator_uid, visit_id, holder_uid), -- both endpoints belong
FOREIGN KEY (close_visit_id, site_uid) REFERENCES fm.visit (visit_id, site_uid), -- to the baseline's site FOREIGN KEY (operator_uid, close_visit_id, holder_uid) REFERENCES fm.visit (operator_uid, visit_id, holder_uid), -- to the baseline's holder
CHECK (included OR exclusion_reason IS NOT NULL), CHECK (included OR exclusion_reason IS NOT NULL),
CHECK (NOT included OR end_on > start_on), -- a same-day interval can only be an excluded input CHECK (NOT included OR end_on > start_on), -- a same-day interval can only be an excluded input
EXCLUDE USING gist (baseline_id WITH =, daterange(start_on, end_on, '[)') WITH &&) WHERE (included) EXCLUDE USING gist (baseline_id WITH =, daterange(start_on, end_on, '[)') WITH &&) WHERE (included)
@@ -463,7 +626,7 @@ CREATE TABLE fm.baseline_input ( -- an interval is identified by
-- Claims are corrected by supersession, never in place (design §2.1). Only these columns may change. -- Claims are corrected by supersession, never in place (design §2.1). Only these columns may change.
CREATE FUNCTION fm.mutable_columns(tbl text) RETURNS text[] LANGUAGE sql IMMUTABLE AS $$ CREATE FUNCTION fm.mutable_columns(tbl text) RETURNS text[] LANGUAGE sql IMMUTABLE AS $$
SELECT CASE tbl SELECT CASE tbl
WHEN 'site_location' THEN ARRAY['acceptance','accepted_on','accepted_by_role','acceptance_ref','superseded_by'] WHEN 'place_location' THEN ARRAY['acceptance','accepted_on','accepted_by_role','acceptance_ref','superseded_by']
WHEN 'assumption' THEN ARRAY['status','accepted_on','accepted_by_role','decision_ref','contradicted_by'] WHEN 'assumption' THEN ARRAY['status','accepted_on','accepted_by_role','decision_ref','contradicted_by']
ELSE ARRAY['claim_status','superseded_by','review_note'] ELSE ARRAY['claim_status','superseded_by','review_note']
END $$; END $$;
@@ -479,7 +642,7 @@ BEGIN
RAISE EXCEPTION '%: only the status columns change in place; a corrected value is a new row that supersedes this one', RAISE EXCEPTION '%: only the status columns change in place; a corrected value is a new row that supersedes this one',
TG_TABLE_NAME; TG_TABLE_NAME;
END IF; END IF;
IF TG_TABLE_NAME = 'site_location' THEN IF TG_TABLE_NAME = 'place_location' THEN
v_kind := 'location'; v_subject := j->>'source_key'; v_fp := j->>'assertion_sha256'; v_verdict := j->>'acceptance'; v_kind := 'location'; v_subject := j->>'source_key'; v_fp := j->>'assertion_sha256'; v_verdict := j->>'acceptance';
IF v_verdict NOT IN ('accepted','rejected') THEN RETURN NEW; END IF; IF v_verdict NOT IN ('accepted','rejected') THEN RETURN NEW; END IF;
ELSIF TG_TABLE_NAME = 'assumption' THEN ELSIF TG_TABLE_NAME = 'assumption' THEN
@@ -490,7 +653,7 @@ BEGIN
IF v_verdict NOT IN ('reviewed','disputed') THEN RETURN NEW; END IF; IF v_verdict NOT IN ('reviewed','disputed') THEN RETURN NEW; END IF;
END IF; END IF;
SELECT * INTO r FROM fm.review x SELECT * INTO r FROM fm.review x
WHERE x.subject_kind = v_kind AND x.subject_key = v_subject AND x.assertion_sha256 = v_fp AND x.verdict = v_verdict WHERE x.operator_uid = j->>'operator_uid' AND x.subject_kind = v_kind AND x.subject_key = v_subject AND x.assertion_sha256 = v_fp AND x.verdict = v_verdict
ORDER BY x.reviewed_on DESC, x.review_id DESC LIMIT 1; ORDER BY x.reviewed_on DESC, x.review_id DESC LIMIT 1;
IF NOT FOUND THEN IF NOT FOUND THEN
RAISE EXCEPTION '%: status % for % needs a review of this exact assertion (%)', RAISE EXCEPTION '%: status % for % needs a review of this exact assertion (%)',
@@ -508,31 +671,31 @@ DECLARE t text;
BEGIN BEGIN
FOREACH t IN ARRAY ARRAY['fuel_movement','stock_observation','hour_meter_reading','electricity_bill', FOREACH t IN ARRAY ARRAY['fuel_movement','stock_observation','hour_meter_reading','electricity_bill',
'equipment_spec','imported_calculation','runtime_interval','engine_fuel_total', 'equipment_spec','imported_calculation','runtime_interval','engine_fuel_total',
'site_location','assumption'] LOOP 'place_location','assumption'] LOOP
EXECUTE format('CREATE TRIGGER status_from_review BEFORE INSERT OR UPDATE ON fm.%I EXECUTE format('CREATE TRIGGER status_from_review BEFORE INSERT OR UPDATE ON fm.%I
FOR EACH ROW EXECUTE FUNCTION fm.tg_status_from_review()', t); FOR EACH ROW EXECUTE FUNCTION fm.tg_status_from_review()', t);
END LOOP; END LOOP;
END $$; END $$;
-- 2, 3, 10. A movement claim: same site as its visit; an internal transfer's two tanks placed at -- 2, 3, 10. A movement claim: same holder as its visit; an internal transfer's two tanks placed on
-- that site on the visit date; the live claims of one event_key share visit, kind and tanks. A -- that holder on the visit date; the live claims of one event_key share visit, kind and tanks. A
-- superseded or retracted claim keeps its own, so a correction can change them. -- superseded or retracted claim keeps its own, so a correction can change them.
CREATE FUNCTION fm.tg_fuel_movement_rules() RETURNS trigger LANGUAGE plpgsql AS $$ CREATE FUNCTION fm.tg_fuel_movement_rules() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v fm.visit; DECLARE v fm.visit;
BEGIN BEGIN
SELECT * INTO v FROM fm.visit WHERE visit_id = NEW.visit_id; SELECT * INTO v FROM fm.visit WHERE visit_id = NEW.visit_id;
IF v.site_uid <> NEW.site_uid THEN IF v.holder_uid <> NEW.holder_uid THEN
RAISE EXCEPTION 'fuel_movement %: site % differs from its visit''s site %', NEW.source_key, NEW.site_uid, v.site_uid; RAISE EXCEPTION 'fuel_movement %: site % differs from its visit''s site %', NEW.source_key, NEW.holder_uid, v.holder_uid;
END IF; END IF;
IF NEW.kind = 'internal_transfer' AND EXISTS ( IF NEW.kind = 'internal_transfer' AND EXISTS (
SELECT 1 FROM unnest(ARRAY[NEW.from_tank_uid, NEW.to_tank_uid]) t(tank) SELECT 1 FROM unnest(ARRAY[NEW.from_tank_uid, NEW.to_tank_uid]) t(tank)
WHERE NOT EXISTS (SELECT 1 FROM fm.asset_placement p WHERE NOT EXISTS (SELECT 1 FROM fm.asset_placement p
WHERE p.asset_uid = t.tank AND p.site_uid = NEW.site_uid WHERE p.asset_uid = t.tank AND p.holder_uid = NEW.holder_uid
AND daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)') @> v.visit_on)) THEN AND daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)') @> v.visit_on)) THEN
RAISE EXCEPTION 'fuel_movement %: both tanks of an internal transfer must be placed at % on %', NEW.source_key, NEW.site_uid, v.visit_on; RAISE EXCEPTION 'fuel_movement %: both tanks of an internal transfer must be placed at % on %', NEW.source_key, NEW.holder_uid, v.visit_on;
END IF; END IF;
IF NEW.claim_status IN ('asserted','reviewed','disputed') AND EXISTS (SELECT 1 FROM fm.fuel_movement m IF NEW.claim_status IN ('asserted','reviewed','disputed') AND EXISTS (SELECT 1 FROM fm.fuel_movement m
WHERE m.event_key = NEW.event_key AND m.movement_id <> NEW.movement_id WHERE m.operator_uid = NEW.operator_uid AND m.event_key = NEW.event_key AND m.movement_id <> NEW.movement_id
AND m.claim_status IN ('asserted','reviewed','disputed') AND m.claim_status IN ('asserted','reviewed','disputed')
AND (m.visit_id, m.kind, m.from_tank_uid, m.to_tank_uid) AND (m.visit_id, m.kind, m.from_tank_uid, m.to_tank_uid)
IS DISTINCT FROM (NEW.visit_id, NEW.kind, NEW.from_tank_uid, NEW.to_tank_uid)) THEN IS DISTINCT FROM (NEW.visit_id, NEW.kind, NEW.from_tank_uid, NEW.to_tank_uid)) THEN
@@ -543,21 +706,21 @@ END $$;
CREATE TRIGGER fuel_movement_rules BEFORE INSERT OR UPDATE ON fm.fuel_movement CREATE TRIGGER fuel_movement_rules BEFORE INSERT OR UPDATE ON fm.fuel_movement
FOR EACH ROW EXECUTE FUNCTION fm.tg_fuel_movement_rules(); FOR EACH ROW EXECUTE FUNCTION fm.tg_fuel_movement_rules();
-- 3. A stock claim belongs to its visit's site. -- 3. A stock claim belongs to its visit's holder.
CREATE FUNCTION fm.tg_stock_observation_rules() RETURNS trigger LANGUAGE plpgsql AS $$ CREATE FUNCTION fm.tg_stock_observation_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM fm.visit v WHERE v.visit_id = NEW.visit_id AND v.site_uid = NEW.site_uid) THEN IF NOT EXISTS (SELECT 1 FROM fm.visit v WHERE v.visit_id = NEW.visit_id AND v.holder_uid = NEW.holder_uid) THEN
RAISE EXCEPTION 'stock_observation %: site % differs from its visit''s site', NEW.source_key, NEW.site_uid; RAISE EXCEPTION 'stock_observation %: site % differs from its visit''s site', NEW.source_key, NEW.holder_uid;
END IF; END IF;
RETURN NEW; RETURN NEW;
END $$; END $$;
CREATE TRIGGER stock_observation_rules BEFORE INSERT OR UPDATE ON fm.stock_observation CREATE TRIGGER stock_observation_rules BEFORE INSERT OR UPDATE ON fm.stock_observation
FOR EACH ROW EXECUTE FUNCTION fm.tg_stock_observation_rules(); FOR EACH ROW EXECUTE FUNCTION fm.tg_stock_observation_rules();
-- 3, 4. A reading's meter belongs to its visit's site; its epoch belongs to its meter. -- 3, 4. A reading's meter belongs to its visit's holder; its epoch belongs to its meter.
CREATE FUNCTION fm.tg_hour_meter_reading_rules() RETURNS trigger LANGUAGE plpgsql AS $$ CREATE FUNCTION fm.tg_hour_meter_reading_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM fm.visit v JOIN fm.runtime_meter m ON m.site_uid = v.site_uid IF NOT EXISTS (SELECT 1 FROM fm.visit v JOIN fm.runtime_meter m ON m.holder_uid = v.holder_uid
WHERE v.visit_id = NEW.visit_id AND m.meter_id = NEW.meter_id) THEN WHERE v.visit_id = NEW.visit_id AND m.meter_id = NEW.meter_id) THEN
RAISE EXCEPTION 'hour_meter_reading %: meter % is not a meter of the visit''s site', NEW.source_key, NEW.meter_id; RAISE EXCEPTION 'hour_meter_reading %: meter % is not a meter of the visit''s site', NEW.source_key, NEW.meter_id;
END IF; END IF;
@@ -570,9 +733,13 @@ END $$;
CREATE TRIGGER hour_meter_reading_rules BEFORE INSERT OR UPDATE ON fm.hour_meter_reading CREATE TRIGGER hour_meter_reading_rules BEFORE INSERT OR UPDATE ON fm.hour_meter_reading
FOR EACH ROW EXECUTE FUNCTION fm.tg_hour_meter_reading_rules(); FOR EACH ROW EXECUTE FUNCTION fm.tg_hour_meter_reading_rules();
-- 7. Supersession stays within one source family. -- 7. Supersession stays within one source family. A document belongs to one customer until
-- document sharing exists.
CREATE FUNCTION fm.tg_source_document_rules() RETURNS trigger LANGUAGE plpgsql AS $$ CREATE FUNCTION fm.tg_source_document_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN BEGIN
IF EXISTS (SELECT 1 FROM fm.source_document d WHERE d.sha256 = NEW.sha256 AND d.operator_uid <> NEW.operator_uid) THEN
RAISE EXCEPTION 'source_document %: already held for another customer; a document cannot be shared yet', left(NEW.sha256, 16);
END IF;
IF NEW.supersedes_sha256 IS NOT NULL AND NOT EXISTS ( IF NEW.supersedes_sha256 IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM fm.source_document d WHERE d.sha256 = NEW.supersedes_sha256 AND d.source_family = NEW.source_family) THEN SELECT 1 FROM fm.source_document d WHERE d.sha256 = NEW.supersedes_sha256 AND d.source_family = NEW.source_family) THEN
RAISE EXCEPTION 'source_document %: it may supersede only a document of family %', left(NEW.sha256, 16), NEW.source_family; RAISE EXCEPTION 'source_document %: it may supersede only a document of family %', left(NEW.sha256, 16), NEW.source_family;
@@ -582,30 +749,32 @@ END $$;
CREATE TRIGGER source_document_rules BEFORE INSERT OR UPDATE ON fm.source_document CREATE TRIGGER source_document_rules BEFORE INSERT OR UPDATE ON fm.source_document
FOR EACH ROW EXECUTE FUNCTION fm.tg_source_document_rules(); FOR EACH ROW EXECUTE FUNCTION fm.tg_source_document_rules();
-- 8. A confirmed or proposed meter mapping names a generator placed at the meter's site at some -- 8. A confirmed or proposed meter mapping names a generator placed on the meter's holder at some
-- time. Whether the placement spans a given interval is fm.v_lph_interval's check. -- time. Whether the placement spans a given interval is fm.v_lph_interval's check.
CREATE FUNCTION fm.tg_runtime_meter_rules() RETURNS trigger LANGUAGE plpgsql AS $$ CREATE FUNCTION fm.tg_runtime_meter_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN BEGIN
IF NEW.mapping_status IN ('confirmed','proposed') AND NOT EXISTS ( IF NEW.mapping_status IN ('confirmed','proposed') AND NOT EXISTS (
SELECT 1 FROM fm.asset_placement p JOIN fm.asset a ON a.asset_uid = p.asset_uid SELECT 1 FROM fm.asset_placement p JOIN fm.asset a ON a.asset_uid = p.asset_uid
WHERE p.asset_uid = NEW.asset_uid AND p.site_uid = NEW.site_uid AND a.kind = 'generator') THEN WHERE p.asset_uid = NEW.asset_uid AND p.holder_uid = NEW.holder_uid AND a.kind = 'generator') THEN
RAISE EXCEPTION 'runtime_meter %: % is not a generator placed at %', NEW.meter_id, NEW.asset_uid, NEW.site_uid; RAISE EXCEPTION 'runtime_meter %: % is not a generator placed at %', NEW.meter_id, NEW.asset_uid, NEW.holder_uid;
END IF; END IF;
RETURN NEW; RETURN NEW;
END $$; END $$;
CREATE TRIGGER runtime_meter_rules BEFORE INSERT OR UPDATE ON fm.runtime_meter CREATE TRIGGER runtime_meter_rules BEFORE INSERT OR UPDATE ON fm.runtime_meter
FOR EACH ROW EXECUTE FUNCTION fm.tg_runtime_meter_rules(); FOR EACH ROW EXECUTE FUNCTION fm.tg_runtime_meter_rules();
-- 12. A generator_idle assumption names a generator placed at its own site during its dates. -- 12. A generator_idle assumption names a generator placed on its own holder during its dates.
-- 8, 9 and 12 stay generator-only: the runtime and L/h calculations have not been checked on
-- an engine, so a meter or idle assumption on one is refused rather than silently unused.
CREATE FUNCTION fm.tg_assumption_rules() RETURNS trigger LANGUAGE plpgsql AS $$ CREATE FUNCTION fm.tg_assumption_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN BEGIN
IF NEW.topic = 'generator_idle' AND NOT EXISTS ( IF NEW.topic = 'generator_idle' AND NOT EXISTS (
SELECT 1 FROM fm.asset_placement p JOIN fm.asset a ON a.asset_uid = p.asset_uid SELECT 1 FROM fm.asset_placement p JOIN fm.asset a ON a.asset_uid = p.asset_uid
WHERE p.asset_uid = NEW.asset_uid AND p.site_uid = NEW.site_uid AND a.kind = 'generator' WHERE p.asset_uid = NEW.asset_uid AND p.holder_uid = NEW.holder_uid AND a.kind = 'generator'
AND daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)') AND daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)')
&& daterange(NEW.applies_from, NEW.applies_to, '[]')) THEN && daterange(NEW.applies_from, NEW.applies_to, '[]')) THEN
RAISE EXCEPTION 'assumption %: % is not a generator placed at % during the assumption''s dates', RAISE EXCEPTION 'assumption %: % is not a generator placed at % during the assumption''s dates',
NEW.code, NEW.asset_uid, NEW.site_uid; NEW.code, NEW.asset_uid, NEW.holder_uid;
END IF; END IF;
RETURN NEW; RETURN NEW;
END $$; END $$;
@@ -629,12 +798,12 @@ BEGIN
END IF; END IF;
IF NEW.member_role = 'counter' THEN IF NEW.member_role = 'counter' THEN
IF NOT EXISTS (SELECT 1 FROM fm.runtime_meter m IF NOT EXISTS (SELECT 1 FROM fm.runtime_meter m
WHERE m.meter_id = NEW.meter_id AND m.site_uid = a.site_uid AND m.mapping_status <> 'confirmed') THEN WHERE m.meter_id = NEW.meter_id AND m.holder_uid = a.holder_uid AND m.mapping_status <> 'confirmed') THEN
RAISE EXCEPTION 'assumption_member: % is not an unconfirmed counter of %', NEW.meter_id, a.site_uid; RAISE EXCEPTION 'assumption_member: % is not an unconfirmed counter of %', NEW.meter_id, a.holder_uid;
END IF; END IF;
ELSIF NOT EXISTS (SELECT 1 FROM fm.asset_placement p JOIN fm.asset s ON s.asset_uid = p.asset_uid ELSIF NOT EXISTS (SELECT 1 FROM fm.asset_placement p JOIN fm.asset s ON s.asset_uid = p.asset_uid
WHERE p.asset_uid = NEW.asset_uid AND p.site_uid = a.site_uid AND s.kind = 'generator') THEN WHERE p.asset_uid = NEW.asset_uid AND p.holder_uid = a.holder_uid AND s.kind = 'generator') THEN
RAISE EXCEPTION 'assumption_member: % is not a generator placed at %', NEW.asset_uid, a.site_uid; RAISE EXCEPTION 'assumption_member: % is not a generator placed at %', NEW.asset_uid, a.holder_uid;
END IF; END IF;
RETURN NEW; RETURN NEW;
END $$; END $$;
@@ -710,7 +879,7 @@ BEGIN
RAISE EXCEPTION 'baseline_input: the inputs of approved version % are immutable', v.code; RAISE EXCEPTION 'baseline_input: the inputs of approved version % are immutable', v.code;
END IF; END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF; IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
IF TG_OP = 'UPDATE' AND (NEW.baseline_id, NEW.site_uid) IS DISTINCT FROM (OLD.baseline_id, OLD.site_uid) THEN IF TG_OP = 'UPDATE' AND (NEW.baseline_id, NEW.holder_uid) IS DISTINCT FROM (OLD.baseline_id, OLD.holder_uid) THEN
RAISE EXCEPTION 'baseline_input: an input of % never moves to another version', v.code; RAISE EXCEPTION 'baseline_input: an input of % never moves to another version', v.code;
END IF; END IF;
IF v.status NOT IN ('draft','proposed') THEN IF v.status NOT IN ('draft','proposed') THEN
@@ -736,6 +905,9 @@ CREATE FUNCTION fm.tg_fixed_columns() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE c text; DECLARE c text;
BEGIN BEGIN
FOREACH c IN ARRAY TG_ARGV LOOP FOREACH c IN ARRAY TG_ARGV LOOP
IF NOT to_jsonb(NEW) ? c THEN
RAISE EXCEPTION '%: tg_fixed_columns names a column it does not have: %', TG_TABLE_NAME, c;
END IF;
IF to_jsonb(OLD)->c IS DISTINCT FROM to_jsonb(NEW)->c THEN IF to_jsonb(OLD)->c IS DISTINCT FROM to_jsonb(NEW)->c THEN
RAISE EXCEPTION '%: % never changes in place', TG_TABLE_NAME, c; RAISE EXCEPTION '%: % never changes in place', TG_TABLE_NAME, c;
END IF; END IF;
@@ -743,14 +915,62 @@ BEGIN
RETURN NEW; RETURN NEW;
END $$; END $$;
CREATE TRIGGER visit_fixed BEFORE UPDATE ON fm.visit CREATE TRIGGER visit_fixed BEFORE UPDATE ON fm.visit
FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('visit_key','site_uid','visit_on','seq_in_day'); FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('visit_key','holder_uid','visit_on','seq_in_day');
CREATE TRIGGER source_document_fixed BEFORE UPDATE ON fm.source_document CREATE TRIGGER source_document_fixed BEFORE UPDATE ON fm.source_document
FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('sha256','source_family','kind'); FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('sha256','source_family','kind');
CREATE TRIGGER asset_fixed BEFORE UPDATE ON fm.asset CREATE TRIGGER asset_fixed BEFORE UPDATE ON fm.asset
FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('asset_uid','kind'); FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('asset_uid','kind');
CREATE TRIGGER runtime_meter_fixed BEFORE UPDATE ON fm.runtime_meter -- readings follow their meter's site CREATE TRIGGER runtime_meter_fixed BEFORE UPDATE ON fm.runtime_meter -- readings follow their meter's site
FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('meter_id','site_uid'); FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('meter_id','holder_uid');
CREATE TRIGGER meter_epoch_fixed BEFORE UPDATE ON fm.meter_epoch -- and their epoch's meter CREATE TRIGGER meter_epoch_fixed BEFORE UPDATE ON fm.meter_epoch -- and their epoch's meter
FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('epoch_id','meter_id'); FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('epoch_id','meter_id');
CREATE TRIGGER place_fixed BEFORE UPDATE ON fm.place
FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('place_uid');
CREATE TRIGGER holder_fixed BEFORE UPDATE ON fm.holder
FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('holder_uid','kind','asset_uid');
-- A row's customer never changes in place, on any table.
DO $$
DECLARE t text;
BEGIN
FOR t IN SELECT c.table_name FROM information_schema.columns c
WHERE c.table_schema = 'fm' AND c.column_name = 'operator_uid' LOOP
EXECUTE format('CREATE TRIGGER operator_fixed BEFORE UPDATE ON fm.%I
FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns(%L)', t, 'operator_uid');
END LOOP;
END $$;
-- 14. A time zone is one PostgreSQL knows: a wrong one would stop every view that uses it.
CREATE FUNCTION fm.tg_timezone_known() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.timezone IS NOT NULL AND NOT EXISTS (SELECT 1 FROM pg_timezone_names n WHERE n.name = NEW.timezone) THEN
RAISE EXCEPTION '%: unknown time zone %', TG_TABLE_NAME, NEW.timezone;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER operator_timezone BEFORE INSERT OR UPDATE ON fm.operator
FOR EACH ROW EXECUTE FUNCTION fm.tg_timezone_known();
CREATE TRIGGER place_timezone BEFORE INSERT OR UPDATE ON fm.place
FOR EACH ROW EXECUTE FUNCTION fm.tg_timezone_known();
-- 15. A vehicle holder's asset is a vehicle; only a fixed holder stands at a place.
CREATE FUNCTION fm.tg_holder_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.asset_uid IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM fm.asset a WHERE a.asset_uid = NEW.asset_uid AND a.kind = 'vehicle') THEN
RAISE EXCEPTION 'holder %: % is not a vehicle', NEW.holder_uid, NEW.asset_uid;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER holder_rules BEFORE INSERT OR UPDATE ON fm.holder
FOR EACH ROW EXECUTE FUNCTION fm.tg_holder_rules();
CREATE FUNCTION fm.tg_holder_place_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM fm.holder h WHERE h.holder_uid = NEW.holder_uid AND h.kind = 'fixed_installation') THEN
RAISE EXCEPTION 'holder_place: % is not a fixed installation', NEW.holder_uid;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER holder_place_rules BEFORE INSERT OR UPDATE ON fm.holder_place
FOR EACH ROW EXECUTE FUNCTION fm.tg_holder_place_rules();
COMMIT; COMMIT;
@@ -1,9 +1,21 @@
-- 0002_fm_derivations.sql: internal derivations in schema `fm` (not visible to Trino). -- 0002_fm_derivations.sql: internal derivations in schema `fm` (not readable by fuel_ro).
-- DRAFT, not applied. Applied after 0001 by a loader acting as fuel_owner (README in this folder). -- DRAFT, not applied. Applied after 0001 by a loader acting as fuel_owner (README in this folder).
-- Design: fuel-monitoring .context/plans/2026-10-24-fuel-db-baseline-dashboard.md, §4 and §5.1. -- Design: fuel-monitoring .context/plans/2026-10-24-fuel-db-baseline-dashboard.md, §4 and §5.1.
BEGIN; BEGIN;
SET LOCAL search_path = fm, pg_catalog, public; SET LOCAL search_path = fm, pg_catalog, public;
-- Each holder's customer, current place and local day. The current place is found by the customer's
-- local day; a holder without a place, or a place without its own zone, uses the customer's zone.
CREATE VIEW fm.v_holder_current AS
SELECT h.holder_uid, h.operator_uid, hp.place_uid,
coalesce(pl.timezone, o.timezone) AS timezone,
(now() AT TIME ZONE coalesce(pl.timezone, o.timezone))::date AS today
FROM fm.holder h
JOIN fm.operator o ON o.operator_uid = h.operator_uid
LEFT JOIN fm.holder_place hp ON hp.holder_uid = h.holder_uid
AND daterange(coalesce(hp.valid_from, '-infinity'::date), hp.valid_to, '[)') @> (now() AT TIME ZONE o.timezone)::date
LEFT JOIN fm.place pl ON pl.place_uid = hp.place_uid;
CREATE VIEW fm.v_source_rank AS CREATE VIEW fm.v_source_rank AS
SELECT sha256, kind, CASE kind WHEN 'delivery_form' THEN 1 WHEN 'site_logbook' THEN 2 SELECT sha256, kind, CASE kind WHEN 'delivery_form' THEN 1 WHEN 'site_logbook' THEN 2
WHEN 'operator_record' THEN 3 WHEN 'operator_workbook' THEN 3 ELSE 9 END AS rank WHEN 'operator_record' THEN 3 WHEN 'operator_workbook' THEN 3 ELSE 9 END AS rank
@@ -74,7 +86,7 @@ HAVING count(*) FILTER (WHERE s.claim_status IN ('asserted','reviewed') AND s.li
-- v1: tank-level rows are summed only in a later version, and only when every placed tank has one. -- v1: tank-level rows are summed only in a later version, and only when every placed tank has one.
CREATE VIEW fm.v_stock_point AS -- stock on arrival and on departure of each live visit CREATE VIEW fm.v_stock_point AS -- stock on arrival and on departure of each live visit
SELECT v.visit_id, v.visit_key, v.site_uid, v.visit_on, v.seq_in_day, v.kind, SELECT v.visit_id, v.visit_key, v.holder_uid, v.visit_on, v.seq_in_day, v.kind,
d.delivered_l, coalesce(o.n_external, 0) AS n_other_movements, d.delivered_l, coalesce(o.n_external, 0) AS n_other_movements,
coalesce(b.litres, st.litres, a.litres - d.delivered_l) AS arrive_l, coalesce(b.litres, st.litres, a.litres - d.delivered_l) AS arrive_l,
CASE WHEN coalesce(b.litres, st.litres) IS NOT NULL THEN 'stated' CASE WHEN coalesce(b.litres, st.litres) IS NOT NULL THEN 'stated'
@@ -104,10 +116,10 @@ LEFT JOIN fm.v_visit_stock st ON st.visit_id = v.visit_id AND st.phase = 'standa
CREATE VIEW fm.v_stock_interval AS -- consecutive stock points per site; no overlap by construction CREATE VIEW fm.v_stock_interval AS -- consecutive stock points per site; no overlap by construction
WITH pts AS ( WITH pts AS (
SELECT p.*, row_number() OVER (PARTITION BY p.site_uid ORDER BY p.visit_on, p.seq_in_day) AS n SELECT p.*, row_number() OVER (PARTITION BY p.holder_uid ORDER BY p.visit_on, p.seq_in_day) AS n
FROM fm.v_stock_point p WHERE p.arrive_l IS NOT NULL OR p.depart_l IS NOT NULL), FROM fm.v_stock_point p WHERE p.arrive_l IS NOT NULL OR p.depart_l IS NOT NULL),
pr AS ( pr AS (
SELECT o.site_uid, o.visit_key || '>' || c.visit_key AS interval_key, SELECT o.holder_uid, o.visit_key || '>' || c.visit_key AS interval_key,
o.visit_id AS open_visit_id, c.visit_id AS close_visit_id, o.visit_id AS open_visit_id, c.visit_id AS close_visit_id,
o.visit_on AS start_on, c.visit_on AS end_on, c.visit_on - o.visit_on AS days, o.visit_on AS start_on, c.visit_on AS end_on, c.visit_on - o.visit_on AS days,
o.depart_l AS opening_l, o.depart_basis AS opening_basis, o.depart_l AS opening_l, o.depart_basis AS opening_basis,
@@ -117,8 +129,8 @@ pr AS (
coalesce(o.depart_reviewed AND c.arrive_reviewed, false) AS endpoints_reviewed, coalesce(o.depart_reviewed AND c.arrive_reviewed, false) AS endpoints_reviewed,
o.n_other_movements + c.n_other_movements AS n_endpoint_movements, o.n_other_movements + c.n_other_movements AS n_endpoint_movements,
o.visit_on AS o_on, o.seq_in_day AS o_seq, c.visit_on AS c_on, c.seq_in_day AS c_seq o.visit_on AS o_on, o.seq_in_day AS o_seq, c.visit_on AS c_on, c.seq_in_day AS c_seq
FROM pts o JOIN pts c ON c.site_uid = o.site_uid AND c.n = o.n + 1) FROM pts o JOIN pts c ON c.holder_uid = o.holder_uid AND c.n = o.n + 1)
SELECT p.site_uid, p.interval_key, p.open_visit_id, p.close_visit_id, p.start_on, p.end_on, p.days, SELECT p.holder_uid, p.interval_key, p.open_visit_id, p.close_visit_id, p.start_on, p.end_on, p.days,
p.opening_l, p.opening_basis, p.closing_l, p.closing_basis, p.opening_l, p.opening_basis, p.closing_l, p.closing_basis,
coalesce(mid.delivered_l, 0) AS deliveries_between_l, coalesce(mid.out_l, 0) AS out_l, coalesce(mid.delivered_l, 0) AS deliveries_between_l, coalesce(mid.out_l, 0) AS out_l,
p.opening_l + coalesce(mid.delivered_l,0) - coalesce(mid.out_l,0) - p.closing_l AS consumed_l, p.opening_l + coalesce(mid.delivered_l,0) - coalesce(mid.out_l,0) - p.closing_l AS consumed_l,
@@ -144,7 +156,7 @@ CROSS JOIN LATERAL (
FROM fm.v_visit_live v FROM fm.v_visit_live v
LEFT JOIN fm.v_visit_delivery d ON d.visit_id = v.visit_id LEFT JOIN fm.v_visit_delivery d ON d.visit_id = v.visit_id
LEFT JOIN fm.v_visit_other_movement x ON x.visit_id = v.visit_id LEFT JOIN fm.v_visit_other_movement x ON x.visit_id = v.visit_id
WHERE v.site_uid = p.site_uid WHERE v.holder_uid = p.holder_uid
AND (v.visit_on, v.seq_in_day) > (p.o_on, p.o_seq) AND (v.visit_on, v.seq_in_day) > (p.o_on, p.o_seq)
AND (v.visit_on, v.seq_in_day) < (p.c_on, p.c_seq)) mid; AND (v.visit_on, v.seq_in_day) < (p.c_on, p.c_seq)) mid;
@@ -180,7 +192,7 @@ WITH gen AS ( -- generators placed at the site at any time in the
bool_or(daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)') bool_or(daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)')
@> daterange(i.start_on, i.end_on, '[]')) AS full_cover @> daterange(i.start_on, i.end_on, '[]')) AS full_cover
FROM fm.v_stock_interval i FROM fm.v_stock_interval i
JOIN fm.asset_placement p ON p.site_uid = i.site_uid JOIN fm.asset_placement p ON p.holder_uid = i.holder_uid
JOIN fm.asset a ON a.asset_uid = p.asset_uid AND a.kind = 'generator' JOIN fm.asset a ON a.asset_uid = p.asset_uid AND a.kind = 'generator'
WHERE daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)') WHERE daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)')
&& daterange(i.start_on, i.end_on, '[]') && daterange(i.start_on, i.end_on, '[]')
@@ -201,13 +213,13 @@ pair AS ( -- every counter read at both endpoints, classified
o.all_reviewed AND c.all_reviewed AS reviewed, o.all_reviewed AND c.all_reviewed AS reviewed,
(o.hours_max > o.hours_min OR c.hours_max > c.hours_min) AS conflict (o.hours_max > o.hours_min OR c.hours_max > c.hours_min) AS conflict
FROM fm.v_stock_interval i FROM fm.v_stock_interval i
JOIN fm.runtime_meter m ON m.site_uid = i.site_uid JOIN fm.runtime_meter m ON m.holder_uid = i.holder_uid
JOIN fm.v_visit_reading o ON o.visit_id = i.open_visit_id AND o.meter_id = m.meter_id JOIN fm.v_visit_reading o ON o.visit_id = i.open_visit_id AND o.meter_id = m.meter_id
JOIN fm.v_visit_reading c ON c.visit_id = i.close_visit_id AND c.meter_id = m.meter_id), JOIN fm.v_visit_reading c ON c.visit_id = i.close_visit_id AND c.meter_id = m.meter_id),
idle AS ( -- at most one live generator_idle per asset and date (EXCLUDE) idle AS ( -- at most one live generator_idle per asset and date (EXCLUDE)
SELECT g.interval_key, g.asset_uid, x.code, x.status SELECT g.interval_key, g.asset_uid, x.code, x.status
FROM gen g JOIN fm.v_stock_interval i USING (interval_key) FROM gen g JOIN fm.v_stock_interval i USING (interval_key)
JOIN fm.assumption x ON x.asset_uid = g.asset_uid AND x.site_uid = i.site_uid -- this site's only JOIN fm.assumption x ON x.asset_uid = g.asset_uid AND x.holder_uid = i.holder_uid -- this site's only
AND x.topic = 'generator_idle' AND x.status IN ('proposed','accepted') AND x.topic = 'generator_idle' AND x.status IN ('proposed','accepted')
AND daterange(x.applies_from, x.applies_to, '[]') @> daterange(i.start_on, i.end_on, '[]')), AND daterange(x.applies_from, x.applies_to, '[]') @> daterange(i.start_on, i.end_on, '[]')),
per_gen AS ( per_gen AS (
@@ -234,7 +246,7 @@ loose AS ( -- counters read at both ends but not tied to a set
SELECT interval_key, array_agg(meter_id ORDER BY meter_id) AS meters SELECT interval_key, array_agg(meter_id ORDER BY meter_id) AS meters
FROM pair WHERE tie = 'not_tied' GROUP BY interval_key), FROM pair WHERE tie = 'not_tied' GROUP BY interval_key),
cova AS ( -- live meter_coverage assumptions with their exact members cova AS ( -- live meter_coverage assumptions with their exact members
SELECT x.assumption_id, x.code, x.site_uid, x.status, x.applies_from, x.applies_to, SELECT x.assumption_id, x.code, x.holder_uid, x.status, x.applies_from, x.applies_to,
coalesce(array_agg(m.meter_id ORDER BY m.meter_id) coalesce(array_agg(m.meter_id ORDER BY m.meter_id)
FILTER (WHERE m.member_role = 'counter'), '{}') AS meters, FILTER (WHERE m.member_role = 'counter'), '{}') AS meters,
coalesce(array_agg(m.asset_uid ORDER BY m.asset_uid) coalesce(array_agg(m.asset_uid ORDER BY m.asset_uid)
@@ -254,7 +266,7 @@ unm AS ( -- at most one live coverage assumption per site an
FROM loose l FROM loose l
JOIN fm.v_stock_interval i USING (interval_key) JOIN fm.v_stock_interval i USING (interval_key)
LEFT JOIN cov cv USING (interval_key) LEFT JOIN cov cv USING (interval_key)
LEFT JOIN cova c ON c.site_uid = i.site_uid LEFT JOIN cova c ON c.holder_uid = i.holder_uid
AND daterange(c.applies_from, c.applies_to, '[]') @> daterange(i.start_on, i.end_on, '[]')), AND daterange(c.applies_from, c.applies_to, '[]') @> daterange(i.start_on, i.end_on, '[]')),
sel AS ( -- the validated population; nothing else is summed sel AS ( -- the validated population; nothing else is summed
SELECT p.* FROM pair p SELECT p.* FROM pair p
@@ -343,7 +355,7 @@ FROM x;
-- M4 (L per calendar day) per stock interval: no runtime, so no generator population. The -- M4 (L per calendar day) per stock interval: no runtime, so no generator population. The
-- population is the site's whole stock. Input to the L/day proposal path only; never a KPI. -- population is the site's whole stock. Input to the L/day proposal path only; never a KPI.
CREATE VIEW fm.v_m4_interval AS CREATE VIEW fm.v_m4_interval AS
SELECT i.*, 'stock:' || i.site_uid AS population_key, SELECT i.*, 'stock:' || i.holder_uid AS population_key,
CASE WHEN i.days > 0 THEN i.consumed_l / i.days END AS l_per_day, CASE WHEN i.days > 0 THEN i.consumed_l / i.days END AS l_per_day,
CASE WHEN i.stock_status = 'ok' THEN 'eligible' ELSE 'excluded' END AS eligibility, CASE WHEN i.stock_status = 'ok' THEN 'eligible' ELSE 'excluded' END AS eligibility,
CASE WHEN i.stock_status <> 'ok' THEN 'stock interval: ' || i.stock_status ELSE '' END AS exclusion_reasons, CASE WHEN i.stock_status <> 'ok' THEN 'stock interval: ' || i.stock_status ELSE '' END AS exclusion_reasons,
@@ -359,7 +371,7 @@ WITH span AS ( -- the endpoint visits and every visit between them
FROM fm.v_stock_interval i FROM fm.v_stock_interval i
JOIN fm.visit o ON o.visit_id = i.open_visit_id JOIN fm.visit o ON o.visit_id = i.open_visit_id
JOIN fm.visit c ON c.visit_id = i.close_visit_id JOIN fm.visit c ON c.visit_id = i.close_visit_id
JOIN fm.visit v ON v.site_uid = i.site_uid JOIN fm.visit v ON v.holder_uid = i.holder_uid
AND (v.visit_on, v.seq_in_day) >= (o.visit_on, o.seq_in_day) AND (v.visit_on, v.seq_in_day) >= (o.visit_on, o.seq_in_day)
AND (v.visit_on, v.seq_in_day) <= (c.visit_on, c.seq_in_day)), AND (v.visit_on, v.seq_in_day) <= (c.visit_on, c.seq_in_day)),
stock_deps AS ( stock_deps AS (
@@ -379,27 +391,27 @@ runtime_deps AS (
FROM fm.hour_meter_reading x WHERE x.visit_id IN (i.open_visit_id, i.close_visit_id) FROM fm.hour_meter_reading x WHERE x.visit_id IN (i.open_visit_id, i.close_visit_id)
UNION ALL UNION ALL
SELECT 'meter:' || m.meter_id || ':' || m.mapping_status || ':' || coalesce(m.asset_uid, '-') SELECT 'meter:' || m.meter_id || ':' || m.mapping_status || ':' || coalesce(m.asset_uid, '-')
FROM fm.runtime_meter m WHERE m.site_uid = i.site_uid FROM fm.runtime_meter m WHERE m.holder_uid = i.holder_uid
UNION ALL UNION ALL
SELECT 'epoch:' || e.meter_id || ':' || e.epoch_id || ':' || coalesce(e.starts_on::text, '-') SELECT 'epoch:' || e.meter_id || ':' || e.epoch_id || ':' || coalesce(e.starts_on::text, '-')
|| ':' || e.start_reason || ':' || e.continuity || ':' || coalesce(e.rollover_at::text, '-') || ':' || e.start_reason || ':' || e.continuity || ':' || coalesce(e.rollover_at::text, '-')
FROM fm.meter_epoch e JOIN fm.runtime_meter m ON m.meter_id = e.meter_id FROM fm.meter_epoch e JOIN fm.runtime_meter m ON m.meter_id = e.meter_id
WHERE m.site_uid = i.site_uid WHERE m.holder_uid = i.holder_uid
UNION ALL -- placements at this site, and anywhere of the assets its meters name UNION ALL -- placements at this site, and anywhere of the assets its meters name
SELECT 'placement:' || p.asset_uid || ':' || p.site_uid || ':' || coalesce(p.occupied_from::text, '-') SELECT 'placement:' || p.asset_uid || ':' || coalesce(p.holder_uid, p.place_uid) || ':' || coalesce(p.occupied_from::text, '-')
|| ':' || coalesce(p.occupied_to::text, '-') || ':' || coalesce(p.occupied_to::text, '-')
FROM fm.asset_placement p FROM fm.asset_placement p
WHERE (p.site_uid = i.site_uid WHERE (p.holder_uid = i.holder_uid
OR p.asset_uid IN (SELECT m.asset_uid FROM fm.runtime_meter m WHERE m.site_uid = i.site_uid)) OR p.asset_uid IN (SELECT m.asset_uid FROM fm.runtime_meter m WHERE m.holder_uid = i.holder_uid))
AND daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)') AND daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)')
&& daterange(i.start_on, i.end_on, '[]') && daterange(i.start_on, i.end_on, '[]')
UNION ALL UNION ALL
SELECT 'assumption:' || x.code || ':' || x.fingerprint || ':' || x.status SELECT 'assumption:' || x.code || ':' || x.fingerprint || ':' || x.status
FROM fm.assumption x FROM fm.assumption x
WHERE x.site_uid = i.site_uid AND x.topic IN ('generator_idle','meter_coverage') WHERE x.holder_uid = i.holder_uid AND x.topic IN ('generator_idle','meter_coverage')
AND daterange(x.applies_from, x.applies_to, '[]') && daterange(i.start_on, i.end_on, '[]')) d AND daterange(x.applies_from, x.applies_to, '[]') && daterange(i.start_on, i.end_on, '[]')) d
GROUP BY i.interval_key) GROUP BY i.interval_key)
SELECT i.interval_key, i.site_uid, i.open_visit_id, i.close_visit_id, SELECT i.interval_key, i.holder_uid, i.open_visit_id, i.close_visit_id,
coalesce(s.deps, '[]'::jsonb) AS stock_deps, coalesce(r.deps, '[]'::jsonb) AS runtime_deps, coalesce(s.deps, '[]'::jsonb) AS stock_deps, coalesce(r.deps, '[]'::jsonb) AS runtime_deps,
encode(sha256(convert_to(coalesce(s.deps, '[]'::jsonb)::text, 'UTF8')), 'hex') AS stock_dep_sha256, encode(sha256(convert_to(coalesce(s.deps, '[]'::jsonb)::text, 'UTF8')), 'hex') AS stock_dep_sha256,
encode(sha256(convert_to(coalesce(r.deps, '[]'::jsonb)::text, 'UTF8')), 'hex') AS runtime_dep_sha256 encode(sha256(convert_to(coalesce(r.deps, '[]'::jsonb)::text, 'UTF8')), 'hex') AS runtime_dep_sha256
@@ -424,11 +436,11 @@ GROUP BY b.baseline_id, b.metric;
-- Consecutive readings per meter (live visits), giving hours per day: R1 / public.v_runtime. -- Consecutive readings per meter (live visits), giving hours per day: R1 / public.v_runtime.
CREATE VIEW fm.v_runtime_pair AS CREATE VIEW fm.v_runtime_pair AS
WITH r AS ( WITH r AS (
SELECT v.site_uid, v.visit_id, v.visit_on, v.seq_in_day, x.meter_id, x.epoch_id, x.n_epochs, SELECT v.holder_uid, v.visit_id, v.visit_on, v.seq_in_day, x.meter_id, x.epoch_id, x.n_epochs,
x.n_unassigned_epoch, x.hours, x.all_reviewed, x.n_unassigned_epoch, x.hours, x.all_reviewed,
row_number() OVER (PARTITION BY x.meter_id ORDER BY v.visit_on, v.seq_in_day) AS n row_number() OVER (PARTITION BY x.meter_id ORDER BY v.visit_on, v.seq_in_day) AS n
FROM fm.v_visit_reading x JOIN fm.v_visit_live v ON v.visit_id = x.visit_id) FROM fm.v_visit_reading x JOIN fm.v_visit_live v ON v.visit_id = x.visit_id)
SELECT o.site_uid, o.meter_id, o.visit_id AS open_visit_id, c.visit_id AS close_visit_id, SELECT o.holder_uid, o.meter_id, o.visit_id AS open_visit_id, c.visit_id AS close_visit_id,
o.visit_on AS start_on, c.visit_on AS end_on, c.visit_on - o.visit_on AS days, o.visit_on AS start_on, c.visit_on AS end_on, c.visit_on - o.visit_on AS days,
c.hours - o.hours AS runtime_h, c.hours - o.hours AS runtime_h,
CASE WHEN c.visit_on > o.visit_on THEN (c.hours - o.hours) / (c.visit_on - o.visit_on) END AS h_per_day, CASE WHEN c.visit_on > o.visit_on THEN (c.hours - o.hours) / (c.visit_on - o.visit_on) END AS h_per_day,
@@ -443,7 +455,7 @@ FROM r o JOIN r c ON c.meter_id = o.meter_id AND c.n = o.n + 1;
-- assumption's dates, divided by that independently sourced runtime assumption (never one derived -- assumption's dates, divided by that independently sourced runtime assumption (never one derived
-- from fuel). The window is the matched one. Reference tab only; never a KPI (design §4.1). -- from fuel). The window is the matched one. Reference tab only; never a KPI (design §4.1).
CREATE VIEW fm.v_modelled_lph AS CREATE VIEW fm.v_modelled_lph AS
SELECT x.site_uid, x.code, x.status, x.basis, SELECT x.holder_uid, x.code, x.status, x.basis,
x.value_num AS h_per_day, x.low_num AS h_per_day_low, x.high_num AS h_per_day_high, x.value_num AS h_per_day, x.low_num AS h_per_day_low, x.high_num AS h_per_day_high,
m4.window_start, m4.window_end, m4.days AS matched_days, m4.fuel_l / NULLIF(m4.days, 0) AS l_per_day, m4.window_start, m4.window_end, m4.days AS matched_days, m4.fuel_l / NULLIF(m4.days, 0) AS l_per_day,
m4.fuel_l / NULLIF(m4.days, 0) / NULLIF(x.value_num, 0) AS modelled_l_per_h, m4.fuel_l / NULLIF(m4.days, 0) / NULLIF(x.value_num, 0) AS modelled_l_per_h,
@@ -454,46 +466,48 @@ CROSS JOIN LATERAL (
SELECT sum(i.consumed_l) AS fuel_l, sum(i.days) AS days, SELECT sum(i.consumed_l) AS fuel_l, sum(i.days) AS days,
min(i.start_on) AS window_start, max(i.end_on) AS window_end min(i.start_on) AS window_start, max(i.end_on) AS window_end
FROM fm.v_m4_interval i FROM fm.v_m4_interval i
WHERE i.site_uid = x.site_uid AND i.eligibility = 'eligible' WHERE i.holder_uid = x.holder_uid AND i.eligibility = 'eligible'
AND daterange(x.applies_from, x.applies_to, '[]') @> daterange(i.start_on, i.end_on, '[]')) m4 AND daterange(x.applies_from, x.applies_to, '[]') @> daterange(i.start_on, i.end_on, '[]')) m4
WHERE x.topic = 'runtime_hours_per_day' AND NOT x.derived_from_fuel AND x.status IN ('proposed','accepted') WHERE x.topic = 'runtime_hours_per_day' AND NOT x.derived_from_fuel AND x.status IN ('proposed','accepted')
AND m4.days > 0; AND m4.days > 0;
-- Review entries whose fingerprint matches no live row: the value changed after it was reviewed, -- Review entries whose fingerprint matches no live row: the value changed after it was reviewed,
-- or the subject was superseded or retracted. The site comes from every row the subject key has -- or the subject was superseded or retracted. The holder comes from every row the subject key has
-- had, in any status; for an asset's specification or engine total, from every site the asset was -- had, in any status; for an asset's specification or engine total, from every holder the asset was
-- placed at. So one entry can give one row per site, and none is lost when its subject leaves. -- placed on; for a location, from every holder that has stood at the place. So one entry can give
-- one row per holder, and none is lost when its subject leaves.
CREATE VIEW fm.v_review_stale AS CREATE VIEW fm.v_review_stale AS
WITH subj AS ( WITH subj AS (
SELECT 'claim' AS kind, source_key AS subject_key, assertion_sha256, site_uid, SELECT operator_uid, 'claim' AS kind, source_key AS subject_key, assertion_sha256, holder_uid,
claim_status IN ('asserted','reviewed','disputed') AS live claim_status IN ('asserted','reviewed','disputed') AS live
FROM fm.fuel_movement FROM fm.fuel_movement
UNION ALL SELECT 'claim', source_key, assertion_sha256, site_uid, claim_status IN ('asserted','reviewed','disputed') UNION ALL SELECT operator_uid, 'claim', source_key, assertion_sha256, holder_uid, claim_status IN ('asserted','reviewed','disputed')
FROM fm.stock_observation FROM fm.stock_observation
UNION ALL SELECT 'claim', h.source_key, h.assertion_sha256, m.site_uid, h.claim_status IN ('asserted','reviewed','disputed') UNION ALL SELECT h.operator_uid, 'claim', h.source_key, h.assertion_sha256, m.holder_uid, h.claim_status IN ('asserted','reviewed','disputed')
FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
UNION ALL SELECT 'claim', source_key, assertion_sha256, site_uid, claim_status IN ('asserted','reviewed','disputed') UNION ALL SELECT operator_uid, 'claim', source_key, assertion_sha256, holder_uid, claim_status IN ('asserted','reviewed','disputed')
FROM fm.electricity_bill FROM fm.electricity_bill
UNION ALL SELECT 'claim', source_key, assertion_sha256, site_uid, claim_status IN ('asserted','reviewed','disputed') UNION ALL SELECT operator_uid, 'claim', source_key, assertion_sha256, holder_uid, claim_status IN ('asserted','reviewed','disputed')
FROM fm.imported_calculation FROM fm.imported_calculation
UNION ALL SELECT 'claim', e.source_key, e.assertion_sha256, p.site_uid, e.claim_status IN ('asserted','reviewed','disputed') UNION ALL SELECT e.operator_uid, 'claim', e.source_key, e.assertion_sha256, p.holder_uid, e.claim_status IN ('asserted','reviewed','disputed')
FROM fm.equipment_spec e LEFT JOIN fm.asset_placement p ON p.asset_uid = e.asset_uid FROM fm.equipment_spec e LEFT JOIN fm.asset_placement p ON p.asset_uid = e.asset_uid
UNION ALL SELECT 'claim', source_key, assertion_sha256, site_uid, claim_status IN ('asserted','reviewed','disputed') UNION ALL SELECT operator_uid, 'claim', source_key, assertion_sha256, holder_uid, claim_status IN ('asserted','reviewed','disputed')
FROM fm.runtime_interval FROM fm.runtime_interval
UNION ALL SELECT 'claim', t.source_key, t.assertion_sha256, p.site_uid, t.claim_status IN ('asserted','reviewed','disputed') UNION ALL SELECT t.operator_uid, 'claim', t.source_key, t.assertion_sha256, p.holder_uid, t.claim_status IN ('asserted','reviewed','disputed')
FROM fm.engine_fuel_total t LEFT JOIN fm.asset_placement p ON p.asset_uid = t.asset_uid FROM fm.engine_fuel_total t LEFT JOIN fm.asset_placement p ON p.asset_uid = t.asset_uid
UNION ALL SELECT 'location', source_key, assertion_sha256, site_uid, acceptance IN ('pending','accepted','rejected') UNION ALL SELECT l.operator_uid, 'location', l.source_key, l.assertion_sha256, hp.holder_uid, l.acceptance IN ('pending','accepted','rejected')
FROM fm.site_location FROM fm.place_location l LEFT JOIN fm.holder_place hp ON hp.place_uid = l.place_uid
UNION ALL SELECT 'assumption', code, fingerprint, site_uid, status <> 'superseded' UNION ALL SELECT operator_uid, 'assumption', code, fingerprint, holder_uid, status <> 'superseded'
FROM fm.assumption) FROM fm.assumption)
SELECT r.review_id, r.entry_key, r.subject_kind, r.subject_key, r.assertion_sha256, r.verdict, SELECT r.operator_uid, r.review_id, r.entry_key, r.subject_kind, r.subject_key, r.assertion_sha256, r.verdict,
r.reviewed_on, r.reviewer_role, st.site_uid r.reviewed_on, r.reviewer_role, st.holder_uid
FROM fm.review r FROM fm.review r
LEFT JOIN LATERAL (SELECT DISTINCT s.site_uid FROM subj s LEFT JOIN LATERAL (SELECT DISTINCT s.holder_uid FROM subj s
WHERE s.kind = r.subject_kind AND s.subject_key = r.subject_key AND s.site_uid IS NOT NULL) st ON true WHERE s.operator_uid = r.operator_uid AND s.kind = r.subject_kind
AND s.subject_key = r.subject_key AND s.holder_uid IS NOT NULL) st ON true
WHERE NOT EXISTS (SELECT 1 FROM subj l WHERE NOT EXISTS (SELECT 1 FROM subj l
WHERE l.live AND l.kind = r.subject_kind AND l.subject_key = r.subject_key WHERE l.live AND l.operator_uid = r.operator_uid AND l.kind = r.subject_kind
AND l.assertion_sha256 = r.assertion_sha256); AND l.subject_key = r.subject_key AND l.assertion_sha256 = r.assertion_sha256);
-- Visits kept for provenance only: every claim superseded or retracted. -- Visits kept for provenance only: every claim superseded or retracted.
CREATE VIEW fm.v_visit_provenance_only AS CREATE VIEW fm.v_visit_provenance_only AS
@@ -1,7 +1,7 @@
-- 0003_bi_views.sql: the views Superset reads through Trino (schema `public`), plus -- 0003_bi_views.sql: the views Superset reads (schema `public`), plus
-- fm.v_site_baseline_status. Owned by fuel_owner, so trino_ro can read them through the existing -- fm.v_site_baseline_status. Owned by fuel_owner, so fuel_ro can read them through the existing
-- default privileges; they run with the owner's rights, so Trino never reads `fm` (it lists fm's -- default privileges; they run with the owner's rights, so fuel_ro never reads `fm` (the catalog
-- table names, as it lists every schema's, but any query on them is refused). -- shows fm's table names, as it shows every schema's, but any query on them is refused).
-- DRAFT, not applied. Applied after 0002 by a loader acting as fuel_owner (README in this folder). -- DRAFT, not applied. Applied after 0002 by a loader acting as fuel_owner (README in this folder).
-- Design: fuel-monitoring .context/plans/2026-10-24-fuel-db-baseline-dashboard.md, §5.2 and §6. -- Design: fuel-monitoring .context/plans/2026-10-24-fuel-db-baseline-dashboard.md, §5.2 and §6.
BEGIN; BEGIN;
@@ -18,16 +18,16 @@ SET LOCAL search_path = fm, pg_catalog, public;
-- insufficient_evidence: none of the above (today: every site, since there is no runtime). -- insufficient_evidence: none of the above (today: every site, since there is no runtime).
CREATE VIEW fm.v_site_baseline_status AS CREATE VIEW fm.v_site_baseline_status AS
WITH cur AS ( WITH cur AS (
SELECT DISTINCT ON (b.site_uid) b.* SELECT DISTINCT ON (b.holder_uid) b.*
FROM fm.baseline_version b FROM fm.baseline_version b
WHERE b.status IN ('approved','proposed') WHERE b.status IN ('approved','proposed')
AND b.metric IN ('measured_generator_l_per_h','apparent_l_per_generator_h', AND b.metric IN ('measured_generator_l_per_h','apparent_l_per_generator_h',
'apparent_l_per_set_h','apparent_l_per_site_dg_h') -- never modelled, never per-day 'apparent_l_per_set_h','apparent_l_per_site_dg_h') -- never modelled, never per-day
ORDER BY b.site_uid, (b.status = 'approved') DESC, b.approved_on DESC NULLS LAST, b.computed_at DESC), ORDER BY b.holder_uid, (b.status = 'approved') DESC, b.approved_on DESC NULLS LAST, b.computed_at DESC),
ev AS ( ev AS (
SELECT site_uid, count(*) FILTER (WHERE eligibility <> 'excluded') AS n_usable SELECT holder_uid, count(*) FILTER (WHERE eligibility <> 'excluded') AS n_usable
FROM fm.v_lph_interval GROUP BY site_uid) FROM fm.v_lph_interval GROUP BY holder_uid)
SELECT s.site_uid, SELECT s.holder_uid,
CASE WHEN cur.status = 'approved' THEN 'approved' CASE WHEN cur.status = 'approved' THEN 'approved'
WHEN cur.status = 'proposed' AND cur.meets_policy AND val.inputs_unchanged THEN 'eligible' WHEN cur.status = 'proposed' AND cur.meets_policy AND val.inputs_unchanged THEN 'eligible'
WHEN cur.status = 'proposed' OR coalesce(ev.n_usable, 0) > 0 THEN 'provisional' WHEN cur.status = 'proposed' OR coalesce(ev.n_usable, 0) > 0 THEN 'provisional'
@@ -42,37 +42,45 @@ SELECT s.site_uid,
cur.metric AS lph_metric, cur.population, cur.window_start AS lph_window_start, cur.metric AS lph_metric, cur.population, cur.window_start AS lph_window_start,
cur.window_end AS lph_window_end, cur.conditions, cur.decision_ref, cur.window_end AS lph_window_end, cur.conditions, cur.decision_ref,
coalesce(ev.n_usable, 0) AS n_usable_intervals coalesce(ev.n_usable, 0) AS n_usable_intervals
FROM fm.site s LEFT JOIN cur USING (site_uid) LEFT JOIN ev USING (site_uid) FROM fm.holder s LEFT JOIN cur USING (holder_uid) LEFT JOIN ev USING (holder_uid)
LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = cur.baseline_id; LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = cur.baseline_id;
-- One row per site, every status: the map dataset and the single source of site-level fields. -- One row per holder, every status: the map dataset and the single source of site-level fields.
-- "Current" means the period that contains today's date in Kabul, not "no end date". -- Public column names stay site_* (a telecom site is a place with one fixed holder); internal fm uses
-- holder and place. "Current" means the period that contains today in the holder's local time.
CREATE VIEW public.v_site AS CREATE VIEW public.v_site AS
WITH today AS (SELECT (now() AT TIME ZONE 'Asia/Kabul')::date AS d), WITH alias AS ( -- the label the customer's people use
alias AS ( -- v1: the operator is ATOMA; later: the site's current operator SELECT a.holder_uid, a.label FROM fm.holder_alias a
SELECT a.site_uid, a.label FROM fm.site_alias a, today JOIN fm.v_holder_current z ON z.holder_uid = a.holder_uid
WHERE a.issuer = 'ATOMA' AND a.is_primary JOIN fm.operator o ON o.operator_uid = z.operator_uid
AND daterange(coalesce(a.valid_from, '-infinity'::date), a.valid_to, '[)') @> today.d), WHERE a.issuer = o.label_issuer AND a.is_primary
pp AS ( -- one programme, chosen explicitly AND daterange(coalesce(a.valid_from, '-infinity'::date), a.valid_to, '[)') @> z.today),
SELECT p.site_uid, p.status FROM fm.pilot_participation p, today pp AS ( -- the customer's one programme
WHERE p.programme = 'ATOMA OFMS pilot' AND daterange(p.valid_from, p.valid_to, '[)') @> today.d), SELECT p.holder_uid, p.status FROM fm.participation p
-- alias and pp are at most one row per site: the exclusion constraints forbid overlapping primary JOIN fm.v_holder_current z ON z.holder_uid = p.holder_uid
-- labels per (site, issuer) and overlapping periods per (site, programme). JOIN fm.operator o ON o.operator_uid = z.operator_uid
loc AS (SELECT site_uid, latitude, longitude, basis, precision_m FROM fm.site_location WHERE acceptance = 'accepted'), WHERE p.programme = o.programme AND daterange(p.valid_from, p.valid_to, '[)') @> z.today),
loc_any AS (SELECT site_uid, bool_or(acceptance = 'pending') AS has_pending FROM fm.site_location GROUP BY site_uid), -- alias and pp are at most one row per holder: the exclusion constraints forbid overlapping primary
cal AS (SELECT site_uid, sum(consumed_l) AS fuel_l, sum(days) AS days -- labels per (holder, issuer) and overlapping periods per (holder, programme).
FROM fm.v_stock_interval WHERE stock_status = 'ok' GROUP BY site_uid), loc AS (SELECT z.holder_uid, l.latitude, l.longitude, l.basis, l.precision_m
latest AS (SELECT site_uid, max(visit_on) AS latest_record_on -- live records only: a fully FROM fm.place_location l JOIN fm.v_holder_current z ON z.place_uid = l.place_uid
FROM fm.v_visit_live GROUP BY site_uid) -- retracted visit does not count WHERE l.acceptance = 'accepted'),
SELECT s.site_uid::varchar(12) AS site_uid, loc_any AS (SELECT z.holder_uid, bool_or(l.acceptance = 'pending') AS has_pending
coalesce(alias.label, s.site_uid)::varchar(20) AS site_code, FROM fm.place_location l JOIN fm.v_holder_current z ON z.place_uid = l.place_uid GROUP BY z.holder_uid),
cal AS (SELECT holder_uid, sum(consumed_l) AS fuel_l, sum(days) AS days
FROM fm.v_stock_interval WHERE stock_status = 'ok' GROUP BY holder_uid),
latest AS (SELECT holder_uid, max(visit_on) AS latest_record_on -- live records only: a fully
FROM fm.v_visit_live GROUP BY holder_uid) -- retracted visit does not count
SELECT s.holder_uid::varchar(12) AS site_uid,
coalesce(alias.label, s.holder_uid)::varchar(20) AS site_code,
coalesce(s.name, 'name not recorded')::varchar(120) AS site_name, coalesce(s.name, 'name not recorded')::varchar(120) AS site_name,
(coalesce(alias.label, s.site_uid) || ' - ' || coalesce(s.name, 'name not recorded') (coalesce(alias.label, s.holder_uid) || ' - ' || coalesce(s.name, 'name not recorded')
|| ' - ' || s.site_uid)::varchar(170) AS site_label, -- the one filter value || ' - ' || s.holder_uid)::varchar(170) AS site_label, -- the one filter value
s.zone::varchar(40) AS zone, s.province::varchar(40) AS province, s.district::varchar(60) AS district, s.operator_uid::varchar(12) AS operator_uid,
pl.zone::varchar(40) AS zone, pl.province::varchar(40) AS province, pl.district::varchar(60) AS district,
coalesce(pp.status, 'none')::varchar(12) AS pilot_status, coalesce(pp.status, 'none')::varchar(12) AS pilot_status,
loc.latitude::numeric(9,6) AS latitude, loc.longitude::numeric(9,6) AS longitude, -- NULL unless accepted loc.latitude::numeric(9,6) AS latitude, loc.longitude::numeric(9,6) AS longitude, -- NULL unless accepted
(CASE WHEN loc.site_uid IS NOT NULL THEN 'accepted' (CASE WHEN loc.holder_uid IS NOT NULL THEN 'accepted'
WHEN loc_any.has_pending THEN 'awaiting acceptance' ELSE 'missing' END)::varchar(20) AS location_status, WHEN loc_any.has_pending THEN 'awaiting acceptance' ELSE 'missing' END)::varchar(20) AS location_status,
loc.basis::varchar(20) AS location_basis, loc.precision_m::integer AS location_precision_m, loc.basis::varchar(20) AS location_basis, loc.precision_m::integer AS location_precision_m,
bs.baseline_status::varchar(24) AS baseline_status, bs.baseline_status::varchar(24) AS baseline_status,
@@ -91,19 +99,22 @@ SELECT s.site_uid::varchar(12) AS site_ui
bs.decision_ref::varchar(12) AS baseline_decision_ref, bs.decision_ref::varchar(12) AS baseline_decision_ref,
round(cal.fuel_l / NULLIF(cal.days, 0), 1)::numeric(7,1) AS fuel_l_per_calendar_day, round(cal.fuel_l / NULLIF(cal.days, 0), 1)::numeric(7,1) AS fuel_l_per_calendar_day,
latest.latest_record_on latest.latest_record_on
FROM fm.site s FROM fm.holder s
LEFT JOIN alias USING (site_uid) LEFT JOIN loc USING (site_uid) LEFT JOIN loc_any USING (site_uid) JOIN fm.v_holder_current z ON z.holder_uid = s.holder_uid
LEFT JOIN pp USING (site_uid) LEFT JOIN fm.v_site_baseline_status bs USING (site_uid) LEFT JOIN fm.place pl ON pl.place_uid = z.place_uid
LEFT JOIN cal USING (site_uid) LEFT JOIN latest USING (site_uid); LEFT JOIN alias ON alias.holder_uid = s.holder_uid LEFT JOIN loc ON loc.holder_uid = s.holder_uid
LEFT JOIN loc_any ON loc_any.holder_uid = s.holder_uid LEFT JOIN pp ON pp.holder_uid = s.holder_uid
LEFT JOIN fm.v_site_baseline_status bs ON bs.holder_uid = s.holder_uid
LEFT JOIN cal ON cal.holder_uid = s.holder_uid LEFT JOIN latest ON latest.holder_uid = s.holder_uid;
-- Every view below joins public.v_site for site_uid, site_code, site_name and site_label, so one -- Every view below joins public.v_site for site_uid, site_code, site_name, site_label and operator_uid, so one
-- Site filter drives every dataset, and casts to Trino-safe types (varchar(n), integer, -- Site filter drives every dataset, and casts to plain types (varchar(n), integer,
-- numeric(p,s), date, timestamp(0), boolean). Live = asserted, reviewed or disputed. -- numeric(p,s), date, timestamp(0), boolean). Live = asserted, reviewed or disputed.
-- S2, R3: one row per site and measure; the rows stay when a value is not established. -- S2, R3: one row per site and measure; the rows stay when a value is not established.
CREATE VIEW public.v_kpi_measures AS CREATE VIEW public.v_kpi_measures AS
WITH cur AS ( -- the site's L/h version; its snapshot only while approved or its inputs unchanged WITH cur AS ( -- the site's L/h version; its snapshot only while approved or its inputs unchanged
SELECT bs.site_uid, b.baseline_id, b.code, b.fuel_l, b.runtime_h, b.matched_days, b.window_start, b.window_end, SELECT bs.holder_uid, b.baseline_id, b.code, b.fuel_l, b.runtime_h, b.matched_days, b.window_start, b.window_end,
(b.status = 'approved' OR coalesce(val.inputs_unchanged, false)) AS shown, (b.status = 'approved' OR coalesce(val.inputs_unchanged, false)) AS shown,
' (' || b.status || ')' ' (' || b.status || ')'
|| CASE WHEN coalesce(val.inputs_unchanged, false) THEN '' || CASE WHEN coalesce(val.inputs_unchanged, false) THEN ''
@@ -112,35 +123,35 @@ WITH cur AS ( -- the site's L/h version; its snapshot only while
FROM fm.v_site_baseline_status bs JOIN fm.baseline_version b ON b.code = bs.baseline_code FROM fm.v_site_baseline_status bs JOIN fm.baseline_version b ON b.code = bs.baseline_code
LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = b.baseline_id), LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = b.baseline_id),
ninp AS (SELECT baseline_id, count(*) FILTER (WHERE included) AS n FROM fm.baseline_input GROUP BY baseline_id), ninp AS (SELECT baseline_id, count(*) FILTER (WHERE included) AS n FROM fm.baseline_input GROUP BY baseline_id),
m4 AS (SELECT site_uid, sum(consumed_l) AS fuel_l, sum(days) AS days, min(start_on) AS first_on, max(end_on) AS last_on m4 AS (SELECT holder_uid, sum(consumed_l) AS fuel_l, sum(days) AS days, min(start_on) AS first_on, max(end_on) AS last_on
FROM fm.v_m4_interval WHERE eligibility = 'eligible' GROUP BY site_uid), FROM fm.v_m4_interval WHERE eligibility = 'eligible' GROUP BY holder_uid),
span AS (SELECT site_uid, min(visit_on) AS first_on, max(visit_on) AS last_on FROM fm.v_visit_live GROUP BY site_uid), span AS (SELECT holder_uid, min(visit_on) AS first_on, max(visit_on) AS last_on FROM fm.v_visit_live GROUP BY holder_uid),
del AS (SELECT v.site_uid, count(*) AS n FROM fm.v_movement_event e JOIN fm.v_visit_live v ON v.visit_id = e.visit_id del AS (SELECT v.holder_uid, count(*) AS n FROM fm.v_movement_event e JOIN fm.v_visit_live v ON v.visit_id = e.visit_id
WHERE e.kind = 'delivery' GROUP BY v.site_uid), WHERE e.kind = 'delivery' GROUP BY v.holder_uid),
rdg AS (SELECT m.site_uid, count(*) AS n FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id rdg AS (SELECT m.holder_uid, count(*) AS n FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
WHERE h.claim_status IN ('asserted','reviewed','disputed') GROUP BY m.site_uid), WHERE h.claim_status IN ('asserted','reviewed','disputed') GROUP BY m.holder_uid),
kwh AS (SELECT site_uid, sum(kwh) AS kwh, count(kwh) AS n_kwh, min(bill_month) AS first_m, max(bill_month) AS last_m kwh AS (SELECT holder_uid, sum(kwh) AS kwh, count(kwh) AS n_kwh, min(bill_month) AS first_m, max(bill_month) AS last_m
FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed') GROUP BY site_uid), -- values never from disputed FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed') GROUP BY holder_uid), -- values never from disputed
lday AS ( -- the L/day reference: the latest approved, else proposed, L/day version lday AS ( -- the L/day reference: the latest approved, else proposed, L/day version
SELECT DISTINCT ON (b.site_uid) b.site_uid, b.code, b.status, b.value, b.window_start, b.window_end, SELECT DISTINCT ON (b.holder_uid) b.holder_uid, b.code, b.status, b.value, b.window_start, b.window_end,
coalesce(val.inputs_unchanged, false) AS inputs_unchanged coalesce(val.inputs_unchanged, false) AS inputs_unchanged
FROM fm.baseline_version b LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = b.baseline_id FROM fm.baseline_version b LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = b.baseline_id
WHERE b.metric = 'fuel_l_per_calendar_day' AND b.status IN ('approved','proposed') WHERE b.metric = 'fuel_l_per_calendar_day' AND b.status IN ('approved','proposed')
ORDER BY b.site_uid, (b.status = 'approved') DESC, b.approved_on DESC NULLS LAST, b.computed_at DESC) ORDER BY b.holder_uid, (b.status = 'approved') DESC, b.approved_on DESC NULLS LAST, b.computed_at DESC)
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
x.ord::integer AS measure_order, x.measure::varchar(60) AS measure, x.ord::integer AS measure_order, x.measure::varchar(60) AS measure,
round(x.value, 2)::numeric(12,2) AS value, x.unit::varchar(12) AS unit, round(x.value, 2)::numeric(12,2) AS value, x.unit::varchar(12) AS unit,
(CASE WHEN x.value IS NULL THEN 'not established' ELSE x.kind END)::varchar(16) AS value_kind, (CASE WHEN x.value IS NULL THEN 'not established' ELSE x.kind END)::varchar(16) AS value_kind,
x.window_start, x.window_end, x.note::varchar(200) AS note x.window_start, x.window_end, x.note::varchar(200) AS note
FROM public.v_site s FROM public.v_site s
LEFT JOIN cur ON cur.site_uid = s.site_uid LEFT JOIN cur ON cur.holder_uid = s.site_uid
LEFT JOIN ninp ON ninp.baseline_id = cur.baseline_id LEFT JOIN ninp ON ninp.baseline_id = cur.baseline_id
LEFT JOIN m4 ON m4.site_uid = s.site_uid LEFT JOIN m4 ON m4.holder_uid = s.site_uid
LEFT JOIN span ON span.site_uid = s.site_uid LEFT JOIN span ON span.holder_uid = s.site_uid
LEFT JOIN del ON del.site_uid = s.site_uid LEFT JOIN del ON del.holder_uid = s.site_uid
LEFT JOIN rdg ON rdg.site_uid = s.site_uid LEFT JOIN rdg ON rdg.holder_uid = s.site_uid
LEFT JOIN kwh ON kwh.site_uid = s.site_uid LEFT JOIN kwh ON kwh.holder_uid = s.site_uid
LEFT JOIN lday ON lday.site_uid = s.site_uid LEFT JOIN lday ON lday.holder_uid = s.site_uid
CROSS JOIN LATERAL (VALUES CROSS JOIN LATERAL (VALUES
(1, 'Matched fuel', CASE WHEN cur.shown THEN cur.fuel_l END, 'L', 'calculated', cur.window_start, cur.window_end, (1, 'Matched fuel', CASE WHEN cur.shown THEN cur.fuel_l END, 'L', 'calculated', cur.window_start, cur.window_end,
'fuel used in the intervals of L/h baseline version ' || cur.code || cur.state), 'fuel used in the intervals of L/h baseline version ' || cur.code || cur.state),
@@ -175,7 +186,7 @@ CROSS JOIN LATERAL (VALUES
-- R2: timer readings as recorded (no rows in v1). -- R2: timer readings as recorded (no rows in v1).
CREATE VIEW public.v_hour_meter_evidence AS CREATE VIEW public.v_hour_meter_evidence AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
m.source_label::varchar(40) AS meter_label, m.source_label::varchar(40) AS meter_label,
(CASE WHEN m.asset_uid IS NULL THEN 'unmapped' (CASE WHEN m.asset_uid IS NULL THEN 'unmapped'
ELSE m.asset_uid || ' (' || m.mapping_status || ')' END)::varchar(60) AS mapped_set, ELSE m.asset_uid || ' (' || m.mapping_status || ')' END)::varchar(60) AS mapped_set,
@@ -186,13 +197,13 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
FROM fm.hour_meter_reading h FROM fm.hour_meter_reading h
JOIN fm.runtime_meter m ON m.meter_id = h.meter_id JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
JOIN fm.visit v ON v.visit_id = h.visit_id JOIN fm.visit v ON v.visit_id = h.visit_id
JOIN public.v_site s ON s.site_uid = m.site_uid JOIN public.v_site s ON s.site_uid = m.holder_uid
LEFT JOIN fm.meter_epoch e ON e.epoch_id = h.epoch_id LEFT JOIN fm.meter_epoch e ON e.epoch_id = h.epoch_id
WHERE h.claim_status IN ('asserted','reviewed','disputed'); WHERE h.claim_status IN ('asserted','reviewed','disputed');
-- B5: assumption versions in force or proposed; a coverage assumption lists its members. -- B5: assumption versions in force or proposed; a coverage assumption lists its members.
CREATE VIEW public.v_assumption AS CREATE VIEW public.v_assumption AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
x.code::varchar(60) AS code, x.topic::varchar(30) AS topic, x.asset_uid::varchar(40) AS asset_uid, x.code::varchar(60) AS code, x.topic::varchar(30) AS topic, x.asset_uid::varchar(40) AS asset_uid,
x.value_num::numeric(10,3) AS value, x.low_num::numeric(10,3) AS value_low, x.value_num::numeric(10,3) AS value, x.low_num::numeric(10,3) AS value_low,
x.high_num::numeric(10,3) AS value_high, x.unit::varchar(20) AS unit, x.high_num::numeric(10,3) AS value_high, x.unit::varchar(20) AS unit,
@@ -201,7 +212,7 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
x.rationale::varchar(400) AS rationale, x.contradicted_by::varchar(200) AS contradicted_by, x.rationale::varchar(400) AS rationale, x.contradicted_by::varchar(200) AS contradicted_by,
mem.members::varchar(400) AS members mem.members::varchar(400) AS members
FROM fm.assumption x FROM fm.assumption x
JOIN public.v_site s ON s.site_uid = x.site_uid JOIN public.v_site s ON s.site_uid = x.holder_uid
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT string_agg(m.member_role || ': ' || coalesce(m.meter_id, m.asset_uid), '; ' SELECT string_agg(m.member_role || ': ' || coalesce(m.meter_id, m.asset_uid), '; '
ORDER BY m.member_role, coalesce(m.meter_id, m.asset_uid)) AS members ORDER BY m.member_role, coalesce(m.meter_id, m.asset_uid)) AS members
@@ -210,7 +221,7 @@ WHERE x.status <> 'superseded';
-- B1: baseline versions, with whether their inputs are unchanged today. -- B1: baseline versions, with whether their inputs are unchanged today.
CREATE VIEW public.v_baseline_version AS CREATE VIEW public.v_baseline_version AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
b.code::varchar(60) AS code, b.metric::varchar(40) AS metric, b.status::varchar(12) AS status, b.code::varchar(60) AS code, b.metric::varchar(40) AS metric, b.status::varchar(12) AS status,
b.population::varchar(200) AS population, b.population::varchar(200) AS population,
round(b.value, 3)::numeric(8,3) AS value, round(b.range_low, 3)::numeric(8,3) AS range_low, round(b.value, 3)::numeric(8,3) AS value, round(b.range_low, 3)::numeric(8,3) AS range_low,
@@ -222,14 +233,15 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
b.evidence_grade::varchar(2) AS evidence_grade, b.conditions::varchar(200) AS conditions, b.evidence_grade::varchar(2) AS evidence_grade, b.conditions::varchar(200) AS conditions,
b.season_note::varchar(200) AS season_note, b.approved_on, b.approved_by_role::varchar(40) AS approved_by_role, b.season_note::varchar(200) AS season_note, b.approved_on, b.approved_by_role::varchar(40) AS approved_by_role,
b.decision_ref::varchar(12) AS decision_ref, coalesce(val.inputs_unchanged, false) AS inputs_unchanged, b.decision_ref::varchar(12) AS decision_ref, coalesce(val.inputs_unchanged, false) AS inputs_unchanged,
(b.computed_at AT TIME ZONE 'Asia/Kabul')::timestamp(0) AS computed_at (b.computed_at AT TIME ZONE z.timezone)::timestamp(0) AS computed_at
FROM fm.baseline_version b FROM fm.baseline_version b
JOIN public.v_site s ON s.site_uid = b.site_uid JOIN public.v_site s ON s.site_uid = b.holder_uid
JOIN fm.v_holder_current z ON z.holder_uid = b.holder_uid
LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = b.baseline_id; LEFT JOIN fm.v_baseline_validity val ON val.baseline_id = b.baseline_id;
-- B2: the inputs of each version, with dependency drift since computation. -- B2: the inputs of each version, with dependency drift since computation.
CREATE VIEW public.v_baseline_input AS CREATE VIEW public.v_baseline_input AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
b.code::varchar(60) AS baseline_code, b.status::varchar(12) AS baseline_status, b.code::varchar(60) AS baseline_code, b.status::varchar(12) AS baseline_status,
(to_char(bi.start_on, 'YYYY-MM-DD') || ' to ' || to_char(bi.end_on, 'YYYY-MM-DD'))::varchar(30) AS interval_label, (to_char(bi.start_on, 'YYYY-MM-DD') || ' to ' || to_char(bi.end_on, 'YYYY-MM-DD'))::varchar(30) AS interval_label,
bi.start_on, bi.end_on, bi.included, bi.exclusion_reason::varchar(300) AS exclusion_reason, bi.start_on, bi.end_on, bi.included, bi.exclusion_reason::varchar(300) AS exclusion_reason,
@@ -241,42 +253,42 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
AND d.runtime_dep_sha256 IS DISTINCT FROM bi.runtime_dep_sha256)) AS dependencies_changed AND d.runtime_dep_sha256 IS DISTINCT FROM bi.runtime_dep_sha256)) AS dependencies_changed
FROM fm.baseline_input bi FROM fm.baseline_input bi
JOIN fm.baseline_version b ON b.baseline_id = bi.baseline_id JOIN fm.baseline_version b ON b.baseline_id = bi.baseline_id
JOIN public.v_site s ON s.site_uid = bi.site_uid JOIN public.v_site s ON s.site_uid = bi.holder_uid
LEFT JOIN fm.v_interval_dependency d ON d.open_visit_id = bi.open_visit_id AND d.close_visit_id = bi.close_visit_id; LEFT JOIN fm.v_interval_dependency d ON d.open_visit_id = bi.open_visit_id AND d.close_visit_id = bi.close_visit_id;
-- B7: documents the site's figures rest on (the document itself is never served). -- B7: documents the site's figures rest on (the document itself is never served).
CREATE VIEW public.v_evidence AS CREATE VIEW public.v_evidence AS
WITH cites AS ( WITH cites AS (
SELECT site_uid, source_sha256, 'fuel movement' AS record_type SELECT holder_uid, source_sha256, 'fuel movement' AS record_type
FROM fm.fuel_movement WHERE claim_status IN ('asserted','reviewed','disputed') FROM fm.fuel_movement WHERE claim_status IN ('asserted','reviewed','disputed')
UNION SELECT site_uid, source_sha256, 'stock reading' UNION SELECT holder_uid, source_sha256, 'stock reading'
FROM fm.stock_observation WHERE claim_status IN ('asserted','reviewed','disputed') FROM fm.stock_observation WHERE claim_status IN ('asserted','reviewed','disputed')
UNION SELECT m.site_uid, h.source_sha256, 'timer reading' UNION SELECT m.holder_uid, h.source_sha256, 'timer reading'
FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
WHERE h.claim_status IN ('asserted','reviewed','disputed') WHERE h.claim_status IN ('asserted','reviewed','disputed')
UNION SELECT site_uid, source_sha256, 'electricity bill' UNION SELECT holder_uid, source_sha256, 'electricity bill'
FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed','disputed') FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed','disputed')
UNION SELECT site_uid, source_sha256, 'imported calculation' UNION SELECT holder_uid, source_sha256, 'imported calculation'
FROM fm.imported_calculation WHERE site_uid IS NOT NULL AND claim_status IN ('asserted','reviewed','disputed') FROM fm.imported_calculation WHERE holder_uid IS NOT NULL AND claim_status IN ('asserted','reviewed','disputed')
UNION SELECT p.site_uid, e.source_sha256, 'equipment specification' UNION SELECT p.holder_uid, e.source_sha256, 'equipment specification'
FROM fm.equipment_spec e JOIN fm.asset_placement p ON p.asset_uid = e.asset_uid FROM fm.equipment_spec e JOIN fm.asset_placement p ON p.asset_uid = e.asset_uid
WHERE e.source_sha256 IS NOT NULL AND e.claim_status IN ('asserted','reviewed','disputed') WHERE e.source_sha256 IS NOT NULL AND e.claim_status IN ('asserted','reviewed','disputed')
UNION SELECT site_uid, source_sha256, 'assumption' UNION SELECT holder_uid, source_sha256, 'assumption'
FROM fm.assumption WHERE source_sha256 IS NOT NULL AND status <> 'superseded') FROM fm.assumption WHERE source_sha256 IS NOT NULL AND status <> 'superseded')
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
left(d.sha256, 16)::varchar(16) AS document_key, d.title::varchar(200) AS title, left(d.sha256, 16)::varchar(16) AS document_key, d.title::varchar(200) AS title,
d.kind::varchar(20) AS kind, d.issuer::varchar(80) AS issuer, d.document_date, d.kind::varchar(20) AS kind, d.issuer::varchar(80) AS issuer, d.document_date,
string_agg(c.record_type, ', ' ORDER BY c.record_type)::varchar(200) AS used_for, string_agg(c.record_type, ', ' ORDER BY c.record_type)::varchar(200) AS used_for,
d.contains_personal_data d.contains_personal_data
FROM cites c FROM cites c
JOIN fm.source_document d ON d.sha256 = c.source_sha256 JOIN fm.source_document d ON d.sha256 = c.source_sha256
JOIN public.v_site s ON s.site_uid = c.site_uid JOIN public.v_site s ON s.site_uid = c.holder_uid
GROUP BY s.site_uid, s.site_code, s.site_name, s.site_label, GROUP BY s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
d.sha256, d.title, d.kind, d.issuer, d.document_date, d.contains_personal_data; d.sha256, d.title, d.kind, d.issuer, d.document_date, d.contains_personal_data;
-- F1, F4: one row per delivery event; quantity NULL = not recorded. No monthly sums. -- F1, F4: one row per delivery event; quantity NULL = not recorded. No monthly sums.
CREATE VIEW public.v_delivery AS CREATE VIEW public.v_delivery AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
e.event_key::varchar(80) AS event_key, v.visit_on AS delivery_date, v.seq_in_day::integer AS seq_in_day, e.event_key::varchar(80) AS event_key, v.visit_on AS delivery_date, v.seq_in_day::integer AS seq_in_day,
round(e.litres, 1)::numeric(8,1) AS delivered_l, round(e.litres, 1)::numeric(8,1) AS delivered_l,
b.unit_price::numeric(8,2) AS unit_price, b.price_currency::varchar(8) AS currency, b.unit_price::numeric(8,2) AS unit_price, b.price_currency::varchar(8) AS currency,
@@ -284,19 +296,19 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
e.n_claims::integer AS n_sources, (e.spread_l > 0) AS sources_disagree, e.all_reviewed AS reviewed e.n_claims::integer AS n_sources, (e.spread_l > 0) AS sources_disagree, e.all_reviewed AS reviewed
FROM fm.v_movement_event e FROM fm.v_movement_event e
JOIN fm.v_visit_live v ON v.visit_id = e.visit_id JOIN fm.v_visit_live v ON v.visit_id = e.visit_id
JOIN public.v_site s ON s.site_uid = v.site_uid JOIN public.v_site s ON s.site_uid = v.holder_uid
CROSS JOIN LATERAL ( -- price and reference of the best-ranked claim; CROSS JOIN LATERAL ( -- price and reference of the best-ranked claim;
-- a disputed claim gives the reference only, never a price -- a disputed claim gives the reference only, never a price
SELECT CASE WHEN m.claim_status <> 'disputed' THEN m.unit_price END AS unit_price, SELECT CASE WHEN m.claim_status <> 'disputed' THEN m.unit_price END AS unit_price,
CASE WHEN m.claim_status <> 'disputed' THEN m.price_currency END AS price_currency, m.record_ref CASE WHEN m.claim_status <> 'disputed' THEN m.price_currency END AS price_currency, m.record_ref
FROM fm.fuel_movement m JOIN fm.v_source_rank r ON r.sha256 = m.source_sha256 FROM fm.fuel_movement m JOIN fm.v_source_rank r ON r.sha256 = m.source_sha256
WHERE m.event_key = e.event_key AND m.claim_status IN ('asserted','reviewed','disputed') WHERE m.visit_id = e.visit_id AND m.event_key = e.event_key AND m.claim_status IN ('asserted','reviewed','disputed')
ORDER BY (m.claim_status = 'disputed'), r.rank, m.movement_id LIMIT 1) b ORDER BY (m.claim_status = 'disputed'), r.rank, m.movement_id LIMIT 1) b
WHERE e.kind = 'delivery'; WHERE e.kind = 'delivery';
-- F2: stock readings as recorded, plus after-delivery points derived as before + delivered. -- F2: stock readings as recorded, plus after-delivery points derived as before + delivered.
CREATE VIEW public.v_stock_reading AS CREATE VIEW public.v_stock_reading AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
v.visit_on AS observed_on, v.seq_in_day::integer AS seq_in_day, v.visit_on AS observed_on, v.seq_in_day::integer AS seq_in_day,
(CASE st.phase WHEN 'before_delivery' THEN 'before delivery' WHEN 'after_delivery' THEN 'after delivery' (CASE st.phase WHEN 'before_delivery' THEN 'before delivery' WHEN 'after_delivery' THEN 'after delivery'
ELSE 'stock check' END)::varchar(20) AS reading_type, ELSE 'stock check' END)::varchar(20) AS reading_type,
@@ -304,20 +316,20 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
round(st.spread_l, 1)::numeric(8,1) AS spread_l, st.all_reviewed AS reviewed round(st.spread_l, 1)::numeric(8,1) AS spread_l, st.all_reviewed AS reviewed
FROM fm.v_visit_stock st FROM fm.v_visit_stock st
JOIN fm.v_visit_live v ON v.visit_id = st.visit_id JOIN fm.v_visit_live v ON v.visit_id = st.visit_id
JOIN public.v_site s ON s.site_uid = v.site_uid JOIN public.v_site s ON s.site_uid = v.holder_uid
UNION ALL UNION ALL
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
p.visit_on, p.seq_in_day::integer, 'after delivery'::varchar(20), p.visit_on, p.seq_in_day::integer, 'after delivery'::varchar(20),
round(p.depart_l, 1)::numeric(8,1), 'calculated'::varchar(12), round(p.depart_l, 1)::numeric(8,1), 'calculated'::varchar(12),
round(p.depart_spread_l, 1)::numeric(8,1), p.depart_reviewed round(p.depart_spread_l, 1)::numeric(8,1), p.depart_reviewed
FROM fm.v_stock_point p FROM fm.v_stock_point p
JOIN public.v_site s ON s.site_uid = p.site_uid JOIN public.v_site s ON s.site_uid = p.holder_uid
WHERE p.depart_basis = 'derived'; WHERE p.depart_basis = 'derived';
-- F3, F4, B3, B4: one row per stock interval, with its L/h evaluation. The approved baseline is set -- F3, F4, B3, B4: one row per stock interval, with its L/h evaluation. The approved baseline is set
-- beside an interval only when both have the same metric and population (B4 compares like with like). -- beside an interval only when both have the same metric and population (B4 compares like with like).
CREATE VIEW public.v_interval AS CREATE VIEW public.v_interval AS
WITH cur AS (SELECT bs.site_uid, bs.baseline_code, bs.baseline_status, bs.lph_estimate, b.metric, b.population_key WITH cur AS (SELECT bs.holder_uid, bs.baseline_code, bs.baseline_status, bs.lph_estimate, b.metric, b.population_key
FROM fm.v_site_baseline_status bs LEFT JOIN fm.baseline_version b ON b.code = bs.baseline_code), FROM fm.v_site_baseline_status bs LEFT JOIN fm.baseline_version b ON b.code = bs.baseline_code),
inb AS ( -- the inputs of each site's KPI version inb AS ( -- the inputs of each site's KPI version
SELECT bi.open_visit_id, bi.close_visit_id, bi.included SELECT bi.open_visit_id, bi.close_visit_id, bi.included
@@ -327,12 +339,12 @@ inb AS ( -- the inputs of each site's KPI vers
cond AS ( -- assumptions a valid interval depends on cond AS ( -- assumptions a valid interval depends on
SELECT l.interval_key, string_agg(DISTINCT x.code, ', ') AS codes SELECT l.interval_key, string_agg(DISTINCT x.code, ', ') AS codes
FROM fm.v_lph_interval l FROM fm.v_lph_interval l
JOIN fm.assumption x ON x.site_uid = l.site_uid AND x.status IN ('proposed','accepted') JOIN fm.assumption x ON x.holder_uid = l.holder_uid AND x.status IN ('proposed','accepted')
AND x.topic IN ('generator_idle','meter_coverage') AND x.topic IN ('generator_idle','meter_coverage')
AND daterange(x.applies_from, x.applies_to, '[]') @> daterange(l.start_on, l.end_on, '[]') AND daterange(x.applies_from, x.applies_to, '[]') @> daterange(l.start_on, l.end_on, '[]')
WHERE l.population_valid WHERE l.population_valid
GROUP BY l.interval_key) GROUP BY l.interval_key)
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
(to_char(l.start_on, 'YYYY-MM-DD') || ' to ' || to_char(l.end_on, 'YYYY-MM-DD'))::varchar(30) AS interval_label, (to_char(l.start_on, 'YYYY-MM-DD') || ' to ' || to_char(l.end_on, 'YYYY-MM-DD'))::varchar(30) AS interval_label,
l.start_on, l.end_on, l.days::integer AS days, l.start_on, l.end_on, l.days::integer AS days,
round(l.opening_l, 1)::numeric(9,1) AS opening_l, l.opening_basis::varchar(8) AS opening_basis, round(l.opening_l, 1)::numeric(9,1) AS opening_l, l.opening_basis::varchar(8) AS opening_basis,
@@ -357,15 +369,15 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
AND l.population_key = cur.population_key AND l.population_key = cur.population_key
THEN cur.lph_estimate END)::numeric(8,3) AS current_baseline_l_per_h THEN cur.lph_estimate END)::numeric(8,3) AS current_baseline_l_per_h
FROM fm.v_lph_interval l FROM fm.v_lph_interval l
JOIN public.v_site s ON s.site_uid = l.site_uid JOIN public.v_site s ON s.site_uid = l.holder_uid
JOIN cur ON cur.site_uid = l.site_uid JOIN cur ON cur.holder_uid = l.holder_uid
LEFT JOIN inb ON inb.open_visit_id = l.open_visit_id AND inb.close_visit_id = l.close_visit_id LEFT JOIN inb ON inb.open_visit_id = l.open_visit_id AND inb.close_visit_id = l.close_visit_id
LEFT JOIN cond ON cond.interval_key = l.interval_key; LEFT JOIN cond ON cond.interval_key = l.interval_key;
-- R1: hours per day by meter interval (no rows in v1). Every pair is listed with its flags; runtime -- R1: hours per day by meter interval (no rows in v1). Every pair is listed with its flags; runtime
-- and h/day only for a valid pair (one epoch, not decreasing, at most 24 h per day). -- and h/day only for a valid pair (one epoch, not decreasing, at most 24 h per day).
CREATE VIEW public.v_runtime AS CREATE VIEW public.v_runtime AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
m.source_label::varchar(40) AS meter_label, m.source_label::varchar(40) AS meter_label,
(CASE WHEN m.asset_uid IS NULL THEN 'unmapped' (CASE WHEN m.asset_uid IS NULL THEN 'unmapped'
ELSE m.asset_uid || ' (' || m.mapping_status || ')' END)::varchar(60) AS mapped_set, ELSE m.asset_uid || ' (' || m.mapping_status || ')' END)::varchar(60) AS mapped_set,
@@ -378,55 +390,56 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
FROM fm.v_runtime_pair r FROM fm.v_runtime_pair r
CROSS JOIN LATERAL (SELECT coalesce(r.same_epoch AND r.monotonic AND NOT r.over_24h_per_day, false) AS pair_valid) v CROSS JOIN LATERAL (SELECT coalesce(r.same_epoch AND r.monotonic AND NOT r.over_24h_per_day, false) AS pair_valid) v
JOIN fm.runtime_meter m ON m.meter_id = r.meter_id JOIN fm.runtime_meter m ON m.meter_id = r.meter_id
JOIN public.v_site s ON s.site_uid = r.site_uid; JOIN public.v_site s ON s.site_uid = r.holder_uid;
-- S3 (Gantt): which periods each kind of evidence covers; Kabul midnight as timestamp(0). -- S3 (Gantt): which periods each kind of evidence covers; local midnight as timestamp(0).
CREATE VIEW public.v_coverage AS CREATE VIEW public.v_coverage AS
WITH spans AS ( WITH spans AS (
SELECT site_uid, '1 Stock intervals' AS lane, stock_status AS status, start_on, end_on SELECT holder_uid, '1 Stock intervals' AS lane, stock_status AS status, start_on, end_on
FROM fm.v_stock_interval FROM fm.v_stock_interval
UNION ALL UNION ALL
SELECT r.site_uid, '2 Runtime: ' || m.source_label, SELECT r.holder_uid, '2 Runtime: ' || m.source_label,
CASE WHEN r.same_epoch AND r.monotonic AND NOT r.over_24h_per_day THEN 'ok' ELSE 'flagged' END, CASE WHEN r.same_epoch AND r.monotonic AND NOT r.over_24h_per_day THEN 'ok' ELSE 'flagged' END,
r.start_on, r.end_on r.start_on, r.end_on
FROM fm.v_runtime_pair r JOIN fm.runtime_meter m ON m.meter_id = r.meter_id FROM fm.v_runtime_pair r JOIN fm.runtime_meter m ON m.meter_id = r.meter_id
UNION ALL UNION ALL
SELECT site_uid, '3 Baseline window', baseline_status, lph_window_start, lph_window_end SELECT holder_uid, '3 Baseline window', baseline_status, lph_window_start, lph_window_end
FROM fm.v_site_baseline_status WHERE lph_window_start IS NOT NULL FROM fm.v_site_baseline_status WHERE lph_window_start IS NOT NULL
UNION ALL UNION ALL
SELECT site_uid, '4 Electricity bills', SELECT holder_uid, '4 Electricity bills',
CASE WHEN claim_status = 'disputed' THEN 'disputed' WHEN kwh IS NULL THEN 'cost only' ELSE 'kWh' END, CASE WHEN claim_status = 'disputed' THEN 'disputed' WHEN kwh IS NULL THEN 'cost only' ELSE 'kWh' END,
coalesce(period_start, bill_month), coalesce(period_end, (bill_month + interval '1 month')::date) coalesce(period_start, bill_month), coalesce(period_end, (bill_month + interval '1 month')::date)
FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed','disputed')) FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed','disputed'))
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
x.lane::varchar(60) AS lane, x.status::varchar(40) AS status, x.start_on, x.end_on, x.lane::varchar(60) AS lane, x.status::varchar(40) AS status, x.start_on, x.end_on,
x.start_on::timestamp(0) AS start_ts, x.end_on::timestamp(0) AS end_ts x.start_on::timestamp(0) AS start_ts, x.end_on::timestamp(0) AS end_ts
FROM spans x JOIN public.v_site s ON s.site_uid = x.site_uid; FROM spans x JOIN public.v_site s ON s.site_uid = x.holder_uid;
-- B9: stock-balance fuel against engine-measured fuel inside the interval. No rows until flow-meter -- B9: stock-balance fuel against engine-measured fuel inside the interval. No rows until flow-meter
-- data exist. The window runs from the opening visit's departure to the closing visit's arrival -- data exist. The window runs from the opening visit's departure to the closing visit's arrival
-- (Kabul time) when both times are recorded, else from midnight to midnight. covered_h = the hours -- (the holder's local time) when both times are recorded, else from midnight to midnight. covered_h = the hours
-- of that window the generator's records cover (live records never overlap per asset). The -- of that window the generator's records cover (live records never overlap per asset). The
-- difference is shown only for an `ok` stock interval with both times recorded, in which every -- difference is shown only for an `ok` stock interval with both times recorded, in which every
-- generator is placed at the site throughout (one placement spans the interval, as in -- generator is placed at the site throughout (one placement spans the interval, as in
-- fm.v_lph_interval) and covered for the whole window. -- fm.v_lph_interval) and covered for the whole window.
CREATE VIEW public.v_reconciliation AS CREATE VIEW public.v_reconciliation AS
WITH gen AS ( -- one row per interval and generator placed at the site at any time in it WITH gen AS ( -- one row per interval and generator placed at the site at any time in it
SELECT l.interval_key, l.site_uid, l.start_on, l.end_on, l.consumed_l, l.stock_status, p.asset_uid, SELECT l.interval_key, l.holder_uid, l.start_on, l.end_on, l.consumed_l, l.stock_status, p.asset_uid,
bool_or(daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)') bool_or(daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)')
@> daterange(l.start_on, l.end_on, '[]')) AS placed_throughout, @> daterange(l.start_on, l.end_on, '[]')) AS placed_throughout,
(o.time_out_local IS NOT NULL AND c.time_in_local IS NOT NULL) AS times_known, (o.time_out_local IS NOT NULL AND c.time_in_local IS NOT NULL) AS times_known,
(l.start_on + coalesce(o.time_out_local, '00:00'::time)) AT TIME ZONE 'Asia/Kabul' AS t0, (l.start_on + coalesce(o.time_out_local, '00:00'::time)) AT TIME ZONE z.timezone AS t0,
(l.end_on + coalesce(c.time_in_local, '00:00'::time)) AT TIME ZONE 'Asia/Kabul' AS t1 (l.end_on + coalesce(c.time_in_local, '00:00'::time)) AT TIME ZONE z.timezone AS t1
FROM fm.v_stock_interval l FROM fm.v_stock_interval l
JOIN fm.visit o ON o.visit_id = l.open_visit_id JOIN fm.visit o ON o.visit_id = l.open_visit_id
JOIN fm.visit c ON c.visit_id = l.close_visit_id JOIN fm.visit c ON c.visit_id = l.close_visit_id
JOIN fm.asset_placement p ON p.site_uid = l.site_uid JOIN fm.v_holder_current z ON z.holder_uid = l.holder_uid
JOIN fm.asset_placement p ON p.holder_uid = l.holder_uid
JOIN fm.asset a ON a.asset_uid = p.asset_uid AND a.kind = 'generator' JOIN fm.asset a ON a.asset_uid = p.asset_uid AND a.kind = 'generator'
WHERE daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)') WHERE daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)')
&& daterange(l.start_on, l.end_on, '[]') && daterange(l.start_on, l.end_on, '[]')
GROUP BY l.interval_key, l.site_uid, l.start_on, l.end_on, l.consumed_l, l.stock_status, p.asset_uid, GROUP BY l.interval_key, l.holder_uid, l.start_on, l.end_on, l.consumed_l, l.stock_status, p.asset_uid,
o.time_out_local, c.time_in_local), o.time_out_local, c.time_in_local, z.timezone),
eng AS ( eng AS (
SELECT g.interval_key, g.asset_uid, sum(t.net_fuel_l) AS engine_l, SELECT g.interval_key, g.asset_uid, sum(t.net_fuel_l) AS engine_l,
sum(extract(epoch FROM t.ended_at - t.started_at)) / 3600 AS covered_h, sum(extract(epoch FROM t.ended_at - t.started_at)) / 3600 AS covered_h,
@@ -440,7 +453,7 @@ tot AS (
count(*) FILTER (WHERE g.placed_throughout AND e.full_cover) AS n_complete, sum(e.engine_l) AS engine_all_l count(*) FILTER (WHERE g.placed_throughout AND e.full_cover) AS n_complete, sum(e.engine_l) AS engine_all_l
FROM gen g LEFT JOIN eng e ON e.interval_key = g.interval_key AND e.asset_uid = g.asset_uid FROM gen g LEFT JOIN eng e ON e.interval_key = g.interval_key AND e.asset_uid = g.asset_uid
GROUP BY g.interval_key) GROUP BY g.interval_key)
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
(to_char(g.start_on, 'YYYY-MM-DD') || ' to ' || to_char(g.end_on, 'YYYY-MM-DD'))::varchar(30) AS interval_label, (to_char(g.start_on, 'YYYY-MM-DD') || ' to ' || to_char(g.end_on, 'YYYY-MM-DD'))::varchar(30) AS interval_label,
g.start_on, g.end_on, g.stock_status::varchar(40) AS stock_status, g.times_known AS endpoint_times_known, g.start_on, g.end_on, g.stock_status::varchar(40) AS stock_status, g.times_known AS endpoint_times_known,
g.asset_uid::varchar(40) AS asset_uid, g.placed_throughout, g.asset_uid::varchar(40) AS asset_uid, g.placed_throughout,
@@ -456,60 +469,60 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
FROM gen g FROM gen g
JOIN eng e ON e.interval_key = g.interval_key AND e.asset_uid = g.asset_uid JOIN eng e ON e.interval_key = g.interval_key AND e.asset_uid = g.asset_uid
JOIN tot t ON t.interval_key = g.interval_key JOIN tot t ON t.interval_key = g.interval_key
JOIN public.v_site s ON s.site_uid = g.site_uid; JOIN public.v_site s ON s.site_uid = g.holder_uid;
-- B6: one row per site and check; rows stay when ok. -- B6: one row per site and check; rows stay when ok.
CREATE VIEW public.v_data_quality AS CREATE VIEW public.v_data_quality AS
WITH c AS ( WITH c AS (
SELECT s.site_uid, SELECT s.holder_uid,
(SELECT count(*) FROM fm.stock_observation o WHERE o.site_uid = s.site_uid (SELECT count(*) FROM fm.stock_observation o WHERE o.holder_uid = s.holder_uid
AND o.litres_basis = 'placeholder_zero' AND o.claim_status IN ('asserted','reviewed','disputed')) AS n_placeholder, AND o.litres_basis = 'placeholder_zero' AND o.claim_status IN ('asserted','reviewed','disputed')) AS n_placeholder,
(SELECT count(*) FROM fm.v_movement_event e JOIN fm.visit v ON v.visit_id = e.visit_id (SELECT count(*) FROM fm.v_movement_event e JOIN fm.visit v ON v.visit_id = e.visit_id
WHERE v.site_uid = s.site_uid AND e.spread_l > 0) WHERE v.holder_uid = s.holder_uid AND e.spread_l > 0)
+ (SELECT count(*) FROM fm.v_visit_stock st JOIN fm.visit v ON v.visit_id = st.visit_id + (SELECT count(*) FROM fm.v_visit_stock st JOIN fm.visit v ON v.visit_id = st.visit_id
WHERE v.site_uid = s.site_uid AND st.spread_l > 0) AS n_conflicts, WHERE v.holder_uid = s.holder_uid AND st.spread_l > 0) AS n_conflicts,
(SELECT count(*) FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id (SELECT count(*) FROM fm.hour_meter_reading h JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
WHERE m.site_uid = s.site_uid AND h.claim_status IN ('asserted','reviewed','disputed')) AS n_readings, WHERE m.holder_uid = s.holder_uid AND h.claim_status IN ('asserted','reviewed','disputed')) AS n_readings,
(SELECT count(*) FROM fm.runtime_meter m WHERE m.site_uid = s.site_uid AND m.mapping_status <> 'confirmed') AS n_unmapped, (SELECT count(*) FROM fm.runtime_meter m WHERE m.holder_uid = s.holder_uid AND m.mapping_status <> 'confirmed') AS n_unmapped,
(SELECT count(*) FROM fm.asset_placement p JOIN fm.asset a ON a.asset_uid = p.asset_uid (SELECT count(*) FROM fm.asset_placement p JOIN fm.asset a ON a.asset_uid = p.asset_uid
WHERE p.site_uid = s.site_uid AND a.kind = 'generator' WHERE p.holder_uid = s.holder_uid AND a.kind = 'generator'
AND daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)') AND daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)')
@> (now() AT TIME ZONE 'Asia/Kabul')::date @> (SELECT z.today FROM fm.v_holder_current z WHERE z.holder_uid = s.holder_uid)
AND NOT EXISTS (SELECT 1 FROM fm.runtime_meter m JOIN fm.hour_meter_reading h ON h.meter_id = m.meter_id AND NOT EXISTS (SELECT 1 FROM fm.runtime_meter m JOIN fm.hour_meter_reading h ON h.meter_id = m.meter_id
WHERE m.asset_uid = p.asset_uid AND m.mapping_status = 'confirmed' WHERE m.asset_uid = p.asset_uid AND m.mapping_status = 'confirmed'
AND h.claim_status IN ('asserted','reviewed'))) AS n_gen_no_readings, AND h.claim_status IN ('asserted','reviewed'))) AS n_gen_no_readings,
(SELECT count(*) FROM fm.fuel_movement x WHERE x.site_uid = s.site_uid AND x.claim_status = 'asserted') (SELECT count(*) FROM fm.fuel_movement x WHERE x.holder_uid = s.holder_uid AND x.claim_status = 'asserted')
+ (SELECT count(*) FROM fm.stock_observation x WHERE x.site_uid = s.site_uid AND x.claim_status = 'asserted') + (SELECT count(*) FROM fm.stock_observation x WHERE x.holder_uid = s.holder_uid AND x.claim_status = 'asserted')
+ (SELECT count(*) FROM fm.hour_meter_reading x JOIN fm.runtime_meter m ON m.meter_id = x.meter_id + (SELECT count(*) FROM fm.hour_meter_reading x JOIN fm.runtime_meter m ON m.meter_id = x.meter_id
WHERE m.site_uid = s.site_uid AND x.claim_status = 'asserted') WHERE m.holder_uid = s.holder_uid AND x.claim_status = 'asserted')
+ (SELECT count(*) FROM fm.electricity_bill x WHERE x.site_uid = s.site_uid AND x.claim_status = 'asserted') AS n_unreviewed, + (SELECT count(*) FROM fm.electricity_bill x WHERE x.holder_uid = s.holder_uid AND x.claim_status = 'asserted') AS n_unreviewed,
(SELECT count(*) FROM fm.v_stock_point p WHERE p.site_uid = s.site_uid (SELECT count(*) FROM fm.v_stock_point p WHERE p.holder_uid = s.holder_uid
AND abs(p.after_vs_before_plus_delivered_l) > 0.5) AS n_after_mismatch, AND abs(p.after_vs_before_plus_delivered_l) > 0.5) AS n_after_mismatch,
(SELECT count(*) FROM fm.v_stock_interval i WHERE i.site_uid = s.site_uid (SELECT count(*) FROM fm.v_stock_interval i WHERE i.holder_uid = s.holder_uid
AND i.stock_status IN ('negative','same_day')) AS n_neg_same_day, AND i.stock_status IN ('negative','same_day')) AS n_neg_same_day,
(SELECT count(*) FROM fm.v_stock_interval i WHERE i.site_uid = s.site_uid (SELECT count(*) FROM fm.v_stock_interval i WHERE i.holder_uid = s.holder_uid
AND i.stock_status NOT IN ('ok','negative','same_day')) AS n_gaps, AND i.stock_status NOT IN ('ok','negative','same_day')) AS n_gaps,
(SELECT count(*) FROM fm.electricity_bill b WHERE b.site_uid = s.site_uid (SELECT count(*) FROM fm.electricity_bill b WHERE b.holder_uid = s.holder_uid
AND b.claim_status IN ('asserted','reviewed','disputed')) AS n_bills, AND b.claim_status IN ('asserted','reviewed','disputed')) AS n_bills,
(SELECT count(*) FROM fm.electricity_bill b WHERE b.site_uid = s.site_uid (SELECT count(*) FROM fm.electricity_bill b WHERE b.holder_uid = s.holder_uid
AND (b.kwh IS NULL OR b.claim_status = 'disputed') AND (b.kwh IS NULL OR b.claim_status = 'disputed')
AND b.claim_status IN ('asserted','reviewed','disputed')) AS n_bills_no_kwh, -- no usable kWh AND b.claim_status IN ('asserted','reviewed','disputed')) AS n_bills_no_kwh, -- no usable kWh
(SELECT count(*) FROM fm.fuel_movement m WHERE m.site_uid = s.site_uid (SELECT count(*) FROM fm.fuel_movement m WHERE m.holder_uid = s.holder_uid
AND m.kind IN ('withdrawal','drain','spill','loss_reported','internal_transfer') AND m.kind IN ('withdrawal','drain','spill','loss_reported','internal_transfer')
AND m.claim_status IN ('asserted','reviewed','disputed')) AS n_transfer_records, AND m.claim_status IN ('asserted','reviewed','disputed')) AS n_transfer_records,
(SELECT count(*) FROM fm.baseline_version b JOIN fm.v_baseline_validity v ON v.baseline_id = b.baseline_id (SELECT count(*) FROM fm.baseline_version b JOIN fm.v_baseline_validity v ON v.baseline_id = b.baseline_id
WHERE b.site_uid = s.site_uid AND b.status IN ('proposed','approved') AND NOT v.inputs_unchanged) AS n_drift, WHERE b.holder_uid = s.holder_uid AND b.status IN ('proposed','approved') AND NOT v.inputs_unchanged) AS n_drift,
(SELECT count(*) FROM fm.v_review_stale r WHERE r.site_uid = s.site_uid) AS n_stale, (SELECT count(*) FROM fm.v_review_stale r WHERE r.holder_uid = s.holder_uid) AS n_stale,
(SELECT count(*) FROM fm.v_visit_provenance_only v WHERE v.site_uid = s.site_uid) AS n_provenance_only, (SELECT count(*) FROM fm.v_visit_provenance_only v WHERE v.holder_uid = s.holder_uid) AS n_provenance_only,
(SELECT count(*) FROM fm.v_lph_interval l WHERE l.site_uid = s.site_uid (SELECT count(*) FROM fm.v_lph_interval l WHERE l.holder_uid = s.holder_uid
AND l.exclusion_reasons LIKE '%confirmed for a generator not placed here%') AS n_unplaced_counter, AND l.exclusion_reasons LIKE '%confirmed for a generator not placed here%') AS n_unplaced_counter,
(SELECT count(*) FROM fm.v_lph_interval l WHERE l.site_uid = s.site_uid (SELECT count(*) FROM fm.v_lph_interval l WHERE l.holder_uid = s.holder_uid
AND l.exclusion_reasons LIKE '%meter-coverage assumption % does not name%') AS n_cov_mismatch AND l.exclusion_reasons LIKE '%meter-coverage assumption % does not name%') AS n_cov_mismatch
FROM fm.site s) FROM fm.holder s)
SELECT vs.site_uid, vs.site_code, vs.site_name, vs.site_label, SELECT vs.site_uid, vs.site_code, vs.site_name, vs.site_label, vs.operator_uid,
x.ord::integer AS check_order, x.code::varchar(40) AS check_code, x.label::varchar(80) AS label, x.ord::integer AS check_order, x.code::varchar(40) AS check_code, x.label::varchar(80) AS label,
x.status::varchar(10) AS status, x.detail::varchar(300) AS detail x.status::varchar(10) AS status, x.detail::varchar(300) AS detail
FROM c JOIN public.v_site vs ON vs.site_uid = c.site_uid FROM c JOIN public.v_site vs ON vs.site_uid = c.holder_uid
CROSS JOIN LATERAL (VALUES CROSS JOIN LATERAL (VALUES
(1, 'placeholder_rows', 'Placeholder rows', (1, 'placeholder_rows', 'Placeholder rows',
CASE WHEN c.n_placeholder > 0 THEN 'warning' ELSE 'ok' END, CASE WHEN c.n_placeholder > 0 THEN 'warning' ELSE 'ok' END,
@@ -564,8 +577,8 @@ CROSS JOIN LATERAL (VALUES
-- B10: specifications, labelled as such; live rows only. A disputed specification is listed with its -- B10: specifications, labelled as such; live rows only. A disputed specification is listed with its
-- status and without its value. -- status and without its value.
CREATE VIEW public.v_spec_reference AS CREATE VIEW public.v_spec_reference AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
p.slot_label::varchar(20) AS slot, e.asset_uid::varchar(40) AS asset_uid, a.kind::varchar(10) AS asset_kind, p.slot_label::varchar(20) AS slot, e.asset_uid::varchar(40) AS asset_uid, a.kind::varchar(20) AS asset_kind,
concat_ws(' ', a.make, a.model)::varchar(120) AS make_model, concat_ws(' ', a.make, a.model)::varchar(120) AS make_model,
e.attribute::varchar(40) AS attribute, e.load_pct::numeric(5,1) AS load_pct, e.attribute::varchar(40) AS attribute, e.load_pct::numeric(5,1) AS load_pct,
(CASE WHEN e.claim_status <> 'disputed' THEN e.value_num END)::numeric(12,3) AS value, (CASE WHEN e.claim_status <> 'disputed' THEN e.value_num END)::numeric(12,3) AS value,
@@ -575,14 +588,14 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
'specification, not measured'::varchar(30) AS value_kind, e.claim_status::varchar(12) AS claim_status 'specification, not measured'::varchar(30) AS value_kind, e.claim_status::varchar(12) AS claim_status
FROM fm.equipment_spec e FROM fm.equipment_spec e
JOIN fm.asset a ON a.asset_uid = e.asset_uid JOIN fm.asset a ON a.asset_uid = e.asset_uid
JOIN (SELECT DISTINCT ON (asset_uid, site_uid) asset_uid, site_uid, slot_label JOIN (SELECT DISTINCT ON (asset_uid, holder_uid) asset_uid, holder_uid, slot_label
FROM fm.asset_placement ORDER BY asset_uid, site_uid, occupied_from DESC NULLS LAST) p ON p.asset_uid = e.asset_uid FROM fm.asset_placement ORDER BY asset_uid, holder_uid, occupied_from DESC NULLS LAST) p ON p.asset_uid = e.asset_uid
JOIN public.v_site s ON s.site_uid = p.site_uid JOIN public.v_site s ON s.site_uid = p.holder_uid
WHERE e.claim_status IN ('asserted','reviewed','disputed'); WHERE e.claim_status IN ('asserted','reviewed','disputed');
-- M3 sensitivity table, reference tab only (shown only once Q9 approves a runtime assumption). -- M3 sensitivity table, reference tab only (shown only once Q9 approves a runtime assumption).
CREATE VIEW public.v_modelled_lph AS CREATE VIEW public.v_modelled_lph AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
m.code::varchar(60) AS assumption_code, m.status::varchar(12) AS status, m.basis::varchar(20) AS basis, m.code::varchar(60) AS assumption_code, m.status::varchar(12) AS status, m.basis::varchar(20) AS basis,
m.h_per_day::numeric(5,2) AS h_per_day, m.h_per_day_low::numeric(5,2) AS h_per_day_low, m.h_per_day::numeric(5,2) AS h_per_day, m.h_per_day_low::numeric(5,2) AS h_per_day_low,
m.h_per_day_high::numeric(5,2) AS h_per_day_high, m.window_start, m.window_end, m.h_per_day_high::numeric(5,2) AS h_per_day_high, m.window_start, m.window_end,
@@ -591,31 +604,36 @@ SELECT s.site_uid, s.site_code, s.site_name, s.site_label,
round(m.modelled_l_per_h_low, 2)::numeric(6,2) AS modelled_l_per_h_low, round(m.modelled_l_per_h_low, 2)::numeric(6,2) AS modelled_l_per_h_low,
round(m.modelled_l_per_h_high, 2)::numeric(6,2) AS modelled_l_per_h_high, round(m.modelled_l_per_h_high, 2)::numeric(6,2) AS modelled_l_per_h_high,
'modelled: L per calendar day / assumed runtime; not measured'::varchar(70) AS value_kind 'modelled: L per calendar day / assumed runtime; not measured'::varchar(70) AS value_kind
FROM fm.v_modelled_lph m JOIN public.v_site s ON s.site_uid = m.site_uid; FROM fm.v_modelled_lph m JOIN public.v_site s ON s.site_uid = m.holder_uid;
-- R4: electricity cost per bill month; cost context only, never joined into fuel metrics. A -- R4: electricity cost per bill month; cost context only, never joined into fuel metrics. A
-- disputed bill stays listed, without amount or kWh. -- disputed bill stays listed, without amount or kWh.
CREATE VIEW public.v_electricity_cost AS CREATE VIEW public.v_electricity_cost AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
b.bill_month, b.period_start, b.period_end, (b.period_start IS NOT NULL) AS period_known, b.bill_month, b.period_start, b.period_end, (b.period_start IS NOT NULL) AS period_known,
(CASE WHEN b.claim_status <> 'disputed' THEN b.amount END)::numeric(12,2) AS amount, b.currency::varchar(8) AS currency, (CASE WHEN b.claim_status <> 'disputed' THEN b.amount END)::numeric(12,2) AS amount, b.currency::varchar(8) AS currency,
b.currency_basis::varchar(10) AS currency_basis, b.currency_basis::varchar(10) AS currency_basis,
(CASE WHEN b.claim_status <> 'disputed' THEN b.kwh END)::numeric(10,1) AS kwh, (CASE WHEN b.claim_status <> 'disputed' THEN b.kwh END)::numeric(10,1) AS kwh,
b.record_ref::varchar(60) AS source_ref, b.claim_status::varchar(12) AS claim_status b.record_ref::varchar(60) AS source_ref, b.claim_status::varchar(12) AS claim_status
FROM fm.electricity_bill b JOIN public.v_site s ON s.site_uid = b.site_uid FROM fm.electricity_bill b JOIN public.v_site s ON s.site_uid = b.holder_uid
WHERE b.claim_status IN ('asserted','reviewed','disputed'); WHERE b.claim_status IN ('asserted','reviewed','disputed');
-- B8: operator workbook calculations and our assessment; live rows only. A disputed transcription is -- B8: operator workbook calculations and our assessment; live rows only. A disputed transcription is
-- listed with its status and without its formula or value. -- listed with its status and without its formula or value.
CREATE VIEW public.v_imported_calculation AS CREATE VIEW public.v_imported_calculation AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
c.cell_ref::varchar(40) AS cell_ref, c.source_label::varchar(80) AS source_label, c.cell_ref::varchar(40) AS cell_ref, c.source_label::varchar(80) AS source_label,
(CASE WHEN c.claim_status <> 'disputed' THEN c.formula END)::varchar(200) AS formula, (CASE WHEN c.claim_status <> 'disputed' THEN c.formula END)::varchar(200) AS formula,
(CASE WHEN c.claim_status <> 'disputed' THEN c.cached_value END)::numeric(14,6) AS cached_value, (CASE WHEN c.claim_status <> 'disputed' THEN c.cached_value END)::numeric(14,6) AS cached_value,
c.unit_as_labelled::varchar(30) AS unit_as_labelled, c.our_reading::varchar(300) AS our_reading, c.unit_as_labelled::varchar(30) AS unit_as_labelled, c.our_reading::varchar(300) AS our_reading,
c.baseline_use::varchar(12) AS baseline_use, c.exclusion_reason::varchar(300) AS exclusion_reason, c.baseline_use::varchar(12) AS baseline_use, c.exclusion_reason::varchar(300) AS exclusion_reason,
left(c.source_sha256, 16)::varchar(16) AS document_key, c.claim_status::varchar(12) AS claim_status left(c.source_sha256, 16)::varchar(16) AS document_key, c.claim_status::varchar(12) AS claim_status
FROM fm.imported_calculation c JOIN public.v_site s ON s.site_uid = c.site_uid FROM fm.imported_calculation c JOIN public.v_site s ON s.site_uid = c.holder_uid
WHERE c.claim_status IN ('asserted','reviewed','disputed'); WHERE c.claim_status IN ('asserted','reviewed','disputed');
-- Which Keycloak group is which customer (PD-21). Only server/ reads this, at sign-in; it holds no
-- site data, and no dashboard has a dataset on it.
CREATE VIEW public.v_operator AS
SELECT operator_uid, name, keycloak_group FROM fm.operator;
COMMIT; COMMIT;
@@ -0,0 +1,91 @@
-- 0004_licence.sql: customer licences (PD-20) and plan prices (PD-19), in schema `licence`.
-- DRAFT, not applied. Applied after 0003 by a loader acting as fuel_owner (README in this folder).
-- fuel_ro has no access: its default privileges cover `public` only. The server gets its own role
-- when it starts checking licences. No customer's licence is seeded here; our admins write those.
BEGIN;
CREATE SCHEMA licence AUTHORIZATION fuel_owner;
SET LOCAL search_path = licence, fm, pg_catalog, public; -- fm holds btree_gist
CREATE TABLE licence.plan (
plan_code text PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE licence.plan_price ( -- per sensor per month (PD-19); a change is a new dated row
plan_code text NOT NULL REFERENCES licence.plan,
payment_term text NOT NULL CHECK (payment_term IN ('annual','monthly')),
currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
price numeric(8,2) NOT NULL CHECK (price >= 0),
valid_from date NOT NULL, valid_to date, -- NULL to = still valid
EXCLUDE USING gist (plan_code WITH =, payment_term WITH =, currency WITH =,
daterange(valid_from, valid_to, '[)') WITH &&)
);
CREATE TABLE licence.plan_entitlement (
plan_code text NOT NULL REFERENCES licence.plan,
feature text NOT NULL,
limit_value integer, -- NULL: no limit
PRIMARY KEY (plan_code, feature)
);
CREATE TABLE licence.licence ( -- a renewal is a new row
licence_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
plan_code text NOT NULL REFERENCES licence.plan,
payment_term text NOT NULL CHECK (payment_term IN ('annual','monthly')),
valid_from date NOT NULL,
valid_until date NOT NULL, -- last valid day
grace_until date NOT NULL, -- last day of grace; alarms stop after it
status text NOT NULL CHECK (status IN ('active','suspended')),
contract_ref text NOT NULL, -- the signed agreement or amendment the dates come from
created_by text NOT NULL, -- a role, never a person's name
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (valid_until >= valid_from),
CHECK (grace_until >= valid_until),
EXCLUDE USING gist (operator_uid WITH =, daterange(valid_from, valid_until, '[]') WITH &&)
);
CREATE TABLE licence.licence_override (
licence_id bigint NOT NULL REFERENCES licence.licence,
feature text NOT NULL,
limit_value integer, -- NULL: no limit
PRIMARY KEY (licence_id, feature)
);
-- Prices decided on 30 Oct 2026 (PD-19). The pilot plan costs nothing on either term.
INSERT INTO licence.plan (plan_code, name) VALUES ('pilot', 'Pilot'), ('standard', 'Standard');
INSERT INTO licence.plan_price (plan_code, payment_term, currency, price, valid_from) VALUES
('pilot', 'annual', 'USD', 0.00, '2026-01-01'),
('pilot', 'monthly', 'USD', 0.00, '2026-01-01'),
('standard', 'annual', 'USD', 15.00, '2026-10-30'),
('standard', 'monthly', 'USD', 17.00, '2026-10-30');
-- Every change is logged (PD-20), and the log is never changed.
CREATE TABLE licence.licence_event (
event_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
table_name text NOT NULL,
action text NOT NULL CHECK (action IN ('INSERT','UPDATE','DELETE')),
old_row jsonb, new_row jsonb,
changed_by text NOT NULL DEFAULT current_user,
changed_at timestamptz NOT NULL DEFAULT now()
);
CREATE FUNCTION licence.tg_log_change() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO licence.licence_event (table_name, action, old_row, new_row)
VALUES (TG_TABLE_NAME, TG_OP,
CASE WHEN TG_OP <> 'INSERT' THEN to_jsonb(OLD) END,
CASE WHEN TG_OP <> 'DELETE' THEN to_jsonb(NEW) END);
RETURN NULL;
END $$;
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['plan','plan_price','plan_entitlement','licence','licence_override'] LOOP
EXECUTE format('CREATE TRIGGER log_change AFTER INSERT OR UPDATE OR DELETE ON licence.%I
FOR EACH ROW EXECUTE FUNCTION licence.tg_log_change()', t);
END LOOP;
END $$;
CREATE FUNCTION licence.tg_event_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'licence_event: the log is never changed';
END $$;
CREATE TRIGGER event_immutable BEFORE UPDATE OR DELETE ON licence.licence_event
FOR EACH ROW EXECUTE FUNCTION licence.tg_event_immutable();
COMMIT;
@@ -10,35 +10,54 @@ The design and its rationale live in the fuel-monitoring corpus:
## Files ## Files
| File | Creates | Visible to Trino | | File | Creates | Readable by `fuel_ro` (Superset, Petrolyst server) |
|---|---|---| |---|---|---|
| `0001_fm_core.sql` | schema `fm`, `btree_gist` in it, 25 tables, constraints, trigger-backed rules 1–13 | no | | `0001_fm_core.sql` | schema `fm`, `btree_gist` in it, 29 tables (customers, assets, places and holders, claims, reviews, baselines), constraints, trigger-backed rules 1–15 | no |
| `0002_fm_derivations.sql` | internal views in `fm`: claims → visits → stock intervals → L/h and L/day intervals, dependency hashes, baseline validity | no | | `0002_fm_derivations.sql` | internal views in `fm`: claims → visits → stock intervals → L/h and L/day intervals, dependency hashes, baseline validity | no |
| `0003_bi_views.sql` | `fm.v_site_baseline_status` and the 18 `public.v_*` views Superset reads | yes, read-only | | `0003_bi_views.sql` | `fm.v_site_baseline_status` and the 19 `public.v_*` views Superset reads | yes, read-only |
| `0004_licence.sql` | schema `licence`: plans, prices, entitlements, customer licences, overrides and their change log (PD-19, PD-20). Holds no customer's licence | no |
Each file is one transaction. Apply them in order. Each file is one transaction. Apply them in order.
**The shape (PD-16, PD-18).** Every row in `fm` carries its customer, `operator_uid`, except
`asset_kind`. A child names its parent and the customer together in a composite foreign key, so it
cannot point at another customer's parent. A site is a `place` with one fixed `holder`; holders keep
their `SITE-0001` ids and places get `PLC-0001`. Internal `fm` speaks of holder and place. The public
views keep their `site_*` column names, because dashboards, the app and the server read them; each
gains `operator_uid`. They get renamed, or holder-named siblings, with the first fleet customer.
**Runtime is generator-only.** An engine can be a placed asset, but rules 8, 9 and 12 refuse a
meter or idle assumption on one: the runtime and L/h calculations have not been checked on engines.
**Who depends on the views.** Superset's dashboards list every column (`petrolyst-insights/dashboards/atoma/columns.tsv`;
the import reports drift). The Petrolyst server reads `public.v_site`; its integration test
(petrolyst-platform `server/tests/test_integration.py`) names the columns and types it needs. After
a change to `0003_bi_views.sql`, run both checks. The migrations stay in this repository (open
point 8, closed 30 Oct 2026).
## Applying (on the server) ## Applying (on the server)
1. Create a loader role as in the repository README, "Data loaders". Its sessions act as 1. Create a loader role as in the repository README, "Data loaders". Its sessions act as
`fuel_owner`, so every object belongs to `fuel_owner`. `trino_ro` can then read the `public` `fuel_owner`, so every object belongs to `fuel_owner`. `fuel_ro` can then read the `public`
views through the existing default privileges, with no role or grant changes. views through the existing default privileges, with no role or grant changes.
2. Open the tunnel and apply the three files as the loader: 2. Open the tunnel and apply the four files as the loader:
```sh ```sh
ssh -L 5433:127.0.0.1:5433 petrolyst ssh -L 5433:127.0.0.1:5433 petrolyst
for f in 0001_fm_core 0002_fm_derivations 0003_bi_views; do for f in 0001_fm_core 0002_fm_derivations 0003_bi_views 0004_licence; do
psql -h localhost -p 5433 -U <loader> -d fuel -v ON_ERROR_STOP=1 -f "$f.sql" || break psql -h localhost -p 5433 -U <loader> -d fuel -v ON_ERROR_STOP=1 -f "$f.sql" || break
done done
``` ```
3. Check through Trino (`docker exec -it fuel-monitoring-trino-1 trino`): 3. Check as `fuel_ro`
- `SHOW TABLES FROM fuel.public` lists the 18 `v_*` views; (`docker exec -it petrolyst-fuel-db-1 psql -U postgres -d fuel`, then `SET ROLE fuel_ro;`):
- `SELECT count(*) FROM fuel.fm.site` fails with "permission denied for schema fm". - `\dv public.*` lists the 19 `v_*` views;
- `SELECT count(*) FROM fm.holder;` fails with "permission denied for schema fm";
- `SELECT count(*) FROM licence.plan;` fails with "permission denied for schema licence".
The `public` views are owned by `fuel_owner` and run with its rights, so Trino never needs, and The `public` views are owned by `fuel_owner` and run with its rights, so `fuel_ro` never needs,
never gets, read access to `fm`. Trino's metadata still lists fm's table and column names, as it and never gets, read access to `fm`. PostgreSQL's catalog still shows every schema's table and
lists every schema in fuel-db (TimescaleDB's internal ones included); names only, no rows. column names to any role, so Superset's SQL Lab can list fm's names; names only, no rows.
## Data ## Data
@@ -59,14 +78,15 @@ lists every schema in fuel-db (TimescaleDB's internal ones included); names only
1. Baseline versions start as draft or proposed. Approval needs `meets_policy` and unchanged 1. Baseline versions start as draft or proposed. Approval needs `meets_policy` and unchanged
inputs. Approved rows and their inputs are immutable; the only change is approved → inputs. Approved rows and their inputs are immutable; the only change is approved →
superseded. An input never moves to another version. superseded. An input never moves to another version.
2. An internal transfer's tanks are placed at the movement's site on the visit date. 2. An internal transfer's tanks are placed on the movement's holder on the visit date.
3. A claim's site is its visit's site, and a reading's meter belongs to that site. 3. A claim's holder is its visit's holder, and a reading's meter belongs to that holder.
4. A reading's epoch belongs to its meter. 4. A reading's epoch belongs to its meter.
5. A runtime assumption derived from fuel cannot be accepted. This is a CHECK constraint. 5. A runtime assumption derived from fuel cannot be accepted. This is a CHECK constraint.
6. A baseline input's dates equal its endpoint visits. Included inputs carry the version's 6. A baseline input's dates equal its endpoint visits. Included inputs carry the version's
population. population.
7. A source document supersedes only one of its own family. 7. A source document supersedes only one of its own family. The same document under a second
8. A confirmed or proposed meter mapping names a generator placed at the meter's site. customer is refused until document sharing exists.
8. A confirmed or proposed meter mapping names a generator placed on the meter's holder.
9. Meter-coverage members: 9. Meter-coverage members:
- valid when written, and written in the assumption's own transaction; - valid when written, and written in the assumption's own transaction;
- immutable afterwards; - immutable afterwards;
@@ -74,49 +94,34 @@ lists every schema in fuel-db (TimescaleDB's internal ones included); names only
10. The live claims of one `event_key` share visit, kind and tanks. A superseded or retracted 10. The live claims of one `event_key` share visit, kind and tanks. A superseded or retracted
claim keeps its own, so a correction can change them. claim keeps its own, so a correction can change them.
11. Status comes only from a matching review. Values are never corrected in place. 11. Status comes only from a matching review. Values are never corrected in place.
12. An idle assumption names a generator placed at its own site during its dates. 12. An idle assumption names a generator placed on its own holder during its dates.
13. Identity and classification never change in place: a visit's key, site, date and sequence; a 13. Identity and classification never change in place: a visit's key, holder, date and sequence; a
source document's hash, family and kind (its rank); an asset's uid and kind; a meter's id and source document's hash, family and kind (its rank); an asset's uid and kind; a place's uid; a
site; an epoch's id and meter. A wrong visit date is corrected by a new visit, which the holder's uid, kind and asset; a meter's id and holder; an epoch's id and meter; any row's customer.
superseding claims point to. A wrong visit date is corrected by a new visit, which the superseding claims point to. Naming a
column a table does not have raises an error.
14. A time zone is one PostgreSQL knows.
15. A vehicle holder's asset is a vehicle; only a fixed installation stands at a place.
## Rollback ## Rollback
Before any data matters, run `DROP SCHEMA fm CASCADE` as the loader. This also drops every Before any data matters, run `DROP SCHEMA licence CASCADE; DROP SCHEMA fm CASCADE` as the loader. This
`public.v_*` view, and leaves fuel-db as it was before 0001. also drops every `public.v_*` view, and leaves fuel-db as it was before 0001.
Once data has been loaded, this deletes it. The corpus remains the source, so a re-load restores Once data has been loaded, this deletes it. The corpus remains the source, so a re-load restores
it, except baseline versions and their approvals, which live only in fuel-db: export it, except baseline versions and their approvals, which live only in fuel-db: export
`fm.baseline_version` and `fm.baseline_input` first. `fm.baseline_version` and `fm.baseline_input` first.
## Verified locally (2026-10-24, scratch only) ## Verified locally (2026-10-30, scratch only)
Setup: Not run against the live fuel-db. An earlier set of 150 checks was a scratch script, since removed;
- `timescale/timescaledb:2.31.0-pg17`, with this repository's `01-roles.sh`; these replace it:
- a loader created by the README recipe;
- `trinodb/trino:484`, with this repository's `trino/etc` connected as `trino_ro`.
Results: - **Rules:** `../tests/run.sh` applies these files to a throw-away database and runs `tests/rules.sql`
- **Apply:** all three files applied as the loader, under the loader's default `search_path`. and `tests/read_only.sql`, with synthetic data only: each rule with an accepted and a refused case,
- **Fixture:** the operator workbook of 21 Oct 2026, the five site records, and synthetic sites two customers, a vehicle as asset and holder, licences, and what `fuel_ro` may read.
for the runtime path. - **Loader:** fuel-monitoring's `make selftest` and `bin/fmdb.py dbtest`.
- **Tests:** all 150 checks of the design's §9 pass: 98 written with the migrations, and 52 - **Row comparison:** before this rewrite, all 18 views were dumped from a database loaded from the
regressions for the implementation inspection's findings. These cover: corpus and a recorded review manifest; after it, the same corpus gave identical rows, leaving out
- figures and statuses; `operator_uid`. That one-time comparison covers only data the corpus has; rules with no corpus data
- claims, reviews and revisions; rest on `rules.sql`.
- movement events;
- coverage, placement and idle scope;
- baseline dependencies and approval;
- the L/day path;
- current periods;
- flow-meter reconciliation (coverage, placement, visit times), runtime validity, modelled-L/h
windows, disputed claims supplying no values, the L/day reference, S2's L/h rows following
the version's validity, corrections under one `event_key`, fixed meter and epoch owners, and
population keys that name the generators a coverage assumption covers;
- every refusal listed above.
- **Trino:**
- all 303 columns of the 18 views have identical names, order and types in PostgreSQL and
Trino;
- `fm` is unreadable: a query is refused, though its names are listed (see Applying);
- writes are refused.
- **Not run on the server:** none of this has been run against the live fuel-db.
@@ -0,0 +1,36 @@
-- read_only.sql: what fuel_ro may and may not do. Run by run.sh, logged in as fuel_ro.
\set ON_ERROR_STOP 1
DO $$
DECLARE
v text; n integer := 0; has boolean;
BEGIN
FOREACH v IN ARRAY ARRAY['v_site','v_kpi_measures','v_hour_meter_evidence','v_assumption','v_baseline_version',
'v_baseline_input','v_evidence','v_delivery','v_stock_reading','v_interval','v_runtime',
'v_coverage','v_reconciliation','v_data_quality','v_spec_reference','v_modelled_lph',
'v_electricity_cost','v_imported_calculation','v_operator'] LOOP
EXECUTE format('SELECT count(*) FROM public.%I', v);
SELECT EXISTS (SELECT 1 FROM information_schema.columns c
WHERE c.table_schema = 'public' AND c.table_name = v AND c.column_name = 'operator_uid') INTO has;
IF NOT has THEN RAISE EXCEPTION '% has no operator_uid', v; END IF;
n := n + 1;
END LOOP;
IF n <> 19 THEN RAISE EXCEPTION 'expected 19 views, checked %', n; END IF;
IF (SELECT count(*) FROM information_schema.views WHERE table_schema = 'public' AND table_name LIKE 'v\_%') <> 19 THEN
RAISE EXCEPTION 'public holds a view this list does not name';
END IF;
FOREACH v IN ARRAY ARRAY['fm.holder','fm.operator','licence.licence','licence.plan','licence.licence_event'] LOOP
BEGIN
EXECUTE format('SELECT count(*) FROM %s', v);
RAISE EXCEPTION USING ERRCODE = 'TT001', MESSAGE = v || ' is readable';
EXCEPTION WHEN insufficient_privilege THEN NULL;
END;
END LOOP;
BEGIN
EXECUTE 'CREATE TABLE public.written (a integer)';
RAISE EXCEPTION USING ERRCODE = 'TT001', MESSAGE = 'fuel_ro created a table';
EXCEPTION WHEN insufficient_privilege OR read_only_sql_transaction THEN NULL;
END;
RAISE NOTICE 'read-only role: 19 views readable, each with operator_uid; fm and licence refused; writes refused';
END $$;
@@ -0,0 +1,366 @@
-- rules.sql: every rule of the fuel-db migrations, each with an accepted and a refused case.
-- Synthetic data only. Run by run.sh on a throw-away database, as a loader acting as fuel_owner.
-- A refused case names the words its error must hold, so it fails if a row is refused for another
-- reason than the rule under test.
\set ON_ERROR_STOP 1
\o /dev/null
SET client_min_messages = warning;
SET search_path = fm, pg_catalog, public;
CREATE SCHEMA rules_test;
CREATE TABLE rules_test.result (n bigint GENERATED ALWAYS AS IDENTITY, label text NOT NULL, passed boolean NOT NULL, detail text);
CREATE FUNCTION rules_test.ok(label text, stmt text) RETURNS void LANGUAGE plpgsql AS $$
BEGIN
EXECUTE stmt;
INSERT INTO rules_test.result (label, passed) VALUES (label, true);
EXCEPTION WHEN OTHERS THEN
INSERT INTO rules_test.result (label, passed, detail) VALUES (label, false, 'refused: ' || SQLERRM);
END $$;
-- A refused case always rolls back, so a wrongly accepted row cannot disturb the later cases.
CREATE FUNCTION rules_test.no(label text, want text, stmt text) RETURNS void LANGUAGE plpgsql AS $$
BEGIN
BEGIN
EXECUTE stmt;
RAISE EXCEPTION USING ERRCODE = 'TT001';
EXCEPTION
WHEN SQLSTATE 'TT001' THEN
INSERT INTO rules_test.result (label, passed, detail) VALUES (label, false, 'accepted');
WHEN OTHERS THEN
INSERT INTO rules_test.result (label, passed, detail)
VALUES (label, position(want IN SQLERRM) > 0,
CASE WHEN position(want IN SQLERRM) = 0 THEN 'refused for another reason: ' || SQLERRM END);
END;
END $$;
CREATE FUNCTION rules_test.eq(label text, query text, want text) RETURNS void LANGUAGE plpgsql AS $$
DECLARE got text;
BEGIN
EXECUTE query INTO got;
INSERT INTO rules_test.result (label, passed, detail)
VALUES (label, got IS NOT DISTINCT FROM want, format('got %s, want %s', got, want));
END $$;
CREATE FUNCTION rules_test.rec(op text) RETURNS bigint LANGUAGE sql STABLE AS
$$ SELECT min(import_record_id) FROM fm.import_record WHERE operator_uid = op $$;
CREATE FUNCTION rules_test.batch(op text) RETURNS bigint LANGUAGE sql STABLE AS
$$ SELECT min(import_batch_id) FROM fm.import_batch WHERE operator_uid = op $$;
CREATE FUNCTION rules_test.visit(k text) RETURNS bigint LANGUAGE sql STABLE AS
$$ SELECT visit_id FROM fm.visit WHERE visit_key = k $$;
CREATE FUNCTION rules_test.sha(c text) RETURNS text LANGUAGE sql IMMUTABLE AS
$$ SELECT repeat(c, 64) $$;
-- Two customers ------------------------------------------------------------------------------
INSERT INTO fm.operator (operator_uid, name, label_issuer, programme, timezone, keycloak_group) VALUES
('OPR-0001', 'Customer A', 'Issuer A', 'Programme A', 'Asia/Kabul', '/customer-a'),
('OPR-0002', 'Customer B', 'Issuer B', 'Programme B', 'Europe/Berlin', '/customer-b');
INSERT INTO fm.source_document (sha256, operator_uid, source_family, kind, issuer, title, corpus_path, contains_personal_data) VALUES
(rules_test.sha('a'), 'OPR-0001', 'family-a', 'operator_workbook', 'Issuer A', 'workbook A', 'a.xlsx', false),
(rules_test.sha('b'), 'OPR-0002', 'family-b', 'operator_workbook', 'Issuer B', 'workbook B', 'b.xlsx', false);
INSERT INTO fm.import_batch (operator_uid, source_kind, source_path, source_sha256, importer_version) VALUES
('OPR-0001', 'operator_workbook', 'a.xlsx', rules_test.sha('a'), 'test'),
('OPR-0002', 'operator_workbook', 'b.xlsx', rules_test.sha('b'), 'test');
INSERT INTO fm.import_record (operator_uid, logical_key, source_locator, payload, payload_sha256, first_batch_id, last_seen_batch_id)
SELECT o, 'record-' || o, 'test', '{}', rules_test.sha('c'), rules_test.batch(o), rules_test.batch(o)
FROM unnest(ARRAY['OPR-0001','OPR-0002']) o;
INSERT INTO fm.place (place_uid, operator_uid, role, zone, province, timezone, import_record_id) VALUES
('PLC-0001', 'OPR-0001', 'tower_site', 'z', 'p', NULL, rules_test.rec('OPR-0001')),
('PLC-0002', 'OPR-0002', 'depot', 'z', 'p', 'Asia/Tokyo', rules_test.rec('OPR-0002'));
INSERT INTO fm.holder (holder_uid, operator_uid, kind, name, import_record_id) VALUES
('SITE-0001', 'OPR-0001', 'fixed_installation', 'Holder A', rules_test.rec('OPR-0001')),
('SITE-0002', 'OPR-0002', 'fixed_installation', 'Holder B', rules_test.rec('OPR-0002'));
INSERT INTO fm.holder_place (holder_uid, operator_uid, place_uid, null_reasons) VALUES
('SITE-0001', 'OPR-0001', 'PLC-0001', '{"valid_from": {}}'),
('SITE-0002', 'OPR-0002', 'PLC-0002', '{"valid_from": {}}');
INSERT INTO fm.holder_alias (holder_uid, operator_uid, label, issuer, is_primary, null_reasons) VALUES
('SITE-0001', 'OPR-0001', 'A1', 'Issuer A', true, '{"valid_from": {}}'),
('SITE-0002', 'OPR-0002', 'B1', 'Issuer B', true, '{"valid_from": {}}');
INSERT INTO fm.participation (holder_uid, operator_uid, programme, status, valid_from) VALUES
('SITE-0001', 'OPR-0001', 'Programme A', 'active', '2026-01-01'),
('SITE-0002', 'OPR-0002', 'Programme B', 'active', '2026-01-01');
INSERT INTO fm.asset (asset_uid, operator_uid, kind, import_record_id) VALUES
('AST-A-GEN', 'OPR-0001', 'generator', rules_test.rec('OPR-0001')),
('AST-A-GEN2', 'OPR-0001', 'generator', rules_test.rec('OPR-0001')),
('AST-A-ENG', 'OPR-0001', 'engine', rules_test.rec('OPR-0001')),
('AST-A-T1', 'OPR-0001', 'tank', rules_test.rec('OPR-0001')),
('AST-A-T2', 'OPR-0001', 'tank', rules_test.rec('OPR-0001')),
('AST-B-GEN', 'OPR-0002', 'generator', rules_test.rec('OPR-0002'));
INSERT INTO fm.asset_placement (asset_uid, operator_uid, holder_uid, slot_label, occupied_from, installed_on) VALUES
('AST-A-GEN', 'OPR-0001', 'SITE-0001', 'GEN-01', '2025-01-01', '2025-01-01'),
('AST-A-GEN2', 'OPR-0001', 'SITE-0001', 'GEN-02', '2025-01-01', '2025-01-01'),
('AST-A-ENG', 'OPR-0001', 'SITE-0001', 'ENG-01', '2025-01-01', '2025-01-01'),
('AST-A-T1', 'OPR-0001', 'SITE-0001', 'T1', '2025-01-01', '2025-01-01'),
('AST-B-GEN', 'OPR-0002', 'SITE-0002', 'GEN-01', '2025-01-01', '2025-01-01');
INSERT INTO fm.visit (visit_key, operator_uid, holder_uid, visit_on, kind) VALUES
('SITE-0001:2026-01-01:1', 'OPR-0001', 'SITE-0001', '2026-01-01', 'stock_check'),
('SITE-0001:2026-01-11:1', 'OPR-0001', 'SITE-0001', '2026-01-11', 'stock_check'),
('SITE-0002:2026-01-01:1', 'OPR-0002', 'SITE-0002', '2026-01-01', 'stock_check');
INSERT INTO fm.stock_observation (source_key, assertion_sha256, operator_uid, visit_id, holder_uid, scope, phase, litres, litres_basis, source_sha256, record_ref, import_record_id)
SELECT 'stock:' || k, rules_test.sha('d'), 'OPR-0001', rules_test.visit(k), 'SITE-0001', 'site_total', 'standalone', l, 'stated_without_level',
rules_test.sha('a'), 'test', rules_test.rec('OPR-0001')
FROM (VALUES ('SITE-0001:2026-01-01:1', 100.0), ('SITE-0001:2026-01-11:1', 50.0)) x(k, l);
SELECT rules_test.ok('fixture holds two customers', 'SELECT 1');
SELECT rules_test.no('customers: one Keycloak group is one customer', 'duplicate key',
'UPDATE fm.operator SET keycloak_group = ''/customer-a'' WHERE operator_uid = ''OPR-0002''');
SELECT rules_test.no('customers: the group is a top-level one', 'keycloak_group_check',
'UPDATE fm.operator SET keycloak_group = ''/customer-a/team'' WHERE operator_uid = ''OPR-0001''');
SELECT rules_test.eq('customers: v_operator gives the group', 'SELECT operator_uid FROM public.v_operator WHERE keycloak_group = ''/customer-b''', 'OPR-0002');
-- Rule 1: baseline versions --------------------------------------------------------------------
\set bv 'INSERT INTO fm.baseline_version (code, operator_uid, holder_uid, metric, population, population_key, window_start, window_end, policy_version, meets_policy, computed_at, computed_by_role'
SELECT rules_test.no('rule 1: a version does not start approved', 'starts as draft or proposed',
:'bv' || ', status, approved_on, decision_ref) VALUES (''BL-X'', ''OPR-0001'', ''SITE-0001'', ''fuel_l_per_calendar_day'', ''x'', ''stock:SITE-0001'', ''2026-01-01'', ''2026-01-11'', ''BP-1'', true, now(), ''r'', ''approved'', ''2026-01-12'', ''D-1'')');
SELECT rules_test.ok('rule 1: a version starts proposed',
:'bv' || ', status) VALUES (''BL-1'', ''OPR-0001'', ''SITE-0001'', ''fuel_l_per_calendar_day'', ''x'', ''stock:SITE-0001'', ''2026-01-01'', ''2026-01-11'', ''BP-1'', true, now(), ''r'', ''proposed'')');
SELECT rules_test.ok('rule 1: a version below policy starts proposed',
:'bv' || ', status) VALUES (''BL-2'', ''OPR-0001'', ''SITE-0001'', ''fuel_l_per_calendar_day'', ''x'', ''stock:SITE-0001'', ''2026-01-01'', ''2026-01-11'', ''BP-1'', false, now(), ''r'', ''proposed'')');
SELECT rules_test.no('rule 1: below policy cannot be approved', 'does not meet',
'UPDATE fm.baseline_version SET status = ''approved'', approved_on = ''2026-01-12'', decision_ref = ''D-1'' WHERE code = ''BL-2''');
SELECT rules_test.no('rule 1: no inputs cannot be approved', 'its inputs changed',
'UPDATE fm.baseline_version SET status = ''approved'', approved_on = ''2026-01-12'', decision_ref = ''D-1'' WHERE code = ''BL-1''');
-- Rule 6: inputs (needed before an approval) ------------------------------------------------------
\set bi 'INSERT INTO fm.baseline_input (baseline_id, operator_uid, holder_uid, open_visit_id, close_visit_id, start_on, end_on, included, days, population_key, eligibility_at_computation, dependencies, stock_dep_sha256) SELECT b.baseline_id, ''OPR-0001'', ''SITE-0001'', rules_test.visit(''SITE-0001:2026-01-01:1''), rules_test.visit(''SITE-0001:2026-01-11:1''), '
SELECT rules_test.no('rule 6: dates must equal the endpoint visits', 'differ from the endpoint visits',
:'bi' || '''2026-01-02'', ''2026-01-11'', true, 10, ''stock:SITE-0001'', ''eligible'', ''{}'', d.stock_dep_sha256 FROM fm.baseline_version b, fm.v_interval_dependency d WHERE b.code = ''BL-1'' AND d.holder_uid = ''SITE-0001''');
SELECT rules_test.no('rule 6: an included input carries the population', 'differs from version',
:'bi' || '''2026-01-01'', ''2026-01-11'', true, 10, ''stock:OTHER'', ''eligible'', ''{}'', d.stock_dep_sha256 FROM fm.baseline_version b, fm.v_interval_dependency d WHERE b.code = ''BL-1'' AND d.holder_uid = ''SITE-0001''');
SELECT rules_test.ok('rule 6: a correct input is accepted',
:'bi' || '''2026-01-01'', ''2026-01-11'', true, 10, ''stock:SITE-0001'', ''eligible'', ''{}'', d.stock_dep_sha256 FROM fm.baseline_version b, fm.v_interval_dependency d WHERE b.code = ''BL-1'' AND d.holder_uid = ''SITE-0001''');
SELECT rules_test.ok('rule 1: unchanged inputs and policy met can be approved',
'UPDATE fm.baseline_version SET status = ''approved'', approved_on = ''2026-01-12'', approved_by_role = ''r'', decision_ref = ''D-1'' WHERE code = ''BL-1''');
SELECT rules_test.no('rule 1: an approved version is immutable', 'approved rows are immutable',
'UPDATE fm.baseline_version SET value = 1 WHERE code = ''BL-1''');
SELECT rules_test.no('rule 1: an approved version is never deleted', 'never deleted',
'DELETE FROM fm.baseline_version WHERE code = ''BL-1''');
SELECT rules_test.no('rule 1: an approved version''s inputs are immutable', 'are immutable',
'UPDATE fm.baseline_input SET days = 9 WHERE included');
-- Rules 2, 3, 10: claims -----------------------------------------------------------------------
\set mv 'INSERT INTO fm.fuel_movement (source_key, assertion_sha256, operator_uid, event_key, visit_id, holder_uid, kind, from_tank_uid, to_tank_uid, litres, litres_state, source_sha256, record_ref, import_record_id) VALUES '
SELECT rules_test.no('rule 3: a movement''s holder is its visit''s holder', 'differs from its visit',
:'mv' || '(''m0'', rules_test.sha(''e''), ''OPR-0001'', ''e0'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''SITE-0003'', ''delivery'', NULL, NULL, 10, ''stated'', rules_test.sha(''a''), ''r'', rules_test.rec(''OPR-0001''))');
SELECT rules_test.no('rule 2: an internal transfer''s tanks are placed', 'must be placed',
:'mv' || '(''m1'', rules_test.sha(''e''), ''OPR-0001'', ''e1'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''SITE-0001'', ''internal_transfer'', ''AST-A-T1'', ''AST-A-T2'', 10, ''stated'', rules_test.sha(''a''), ''r'', rules_test.rec(''OPR-0001''))');
INSERT INTO fm.asset_placement (asset_uid, operator_uid, holder_uid, slot_label, occupied_from, installed_on)
VALUES ('AST-A-T2', 'OPR-0001', 'SITE-0001', 'T2', '2025-01-01', '2025-01-01');
SELECT rules_test.ok('rule 2: placed tanks are accepted',
:'mv' || '(''m1'', rules_test.sha(''e''), ''OPR-0001'', ''e1'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''SITE-0001'', ''internal_transfer'', ''AST-A-T1'', ''AST-A-T2'', 10, ''stated'', rules_test.sha(''a''), ''r'', rules_test.rec(''OPR-0001''))');
SELECT rules_test.no('rule 10: claims of one event share kind and tanks', 'must share visit',
:'mv' || '(''m2'', rules_test.sha(''e''), ''OPR-0001'', ''e1'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''SITE-0001'', ''delivery'', NULL, NULL, 10, ''stated'', rules_test.sha(''a''), ''r'', rules_test.rec(''OPR-0001''))');
SELECT rules_test.ok('rule 10: a second claim of one event is accepted',
:'mv' || '(''m2'', rules_test.sha(''e''), ''OPR-0001'', ''e1'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''SITE-0001'', ''internal_transfer'', ''AST-A-T1'', ''AST-A-T2'', 11, ''stated'', rules_test.sha(''a''), ''r'', rules_test.rec(''OPR-0001''))');
SELECT rules_test.no('rule 3: a stock claim''s holder is its visit''s holder', 'differs from its visit',
'INSERT INTO fm.stock_observation (source_key, assertion_sha256, operator_uid, visit_id, holder_uid, scope, phase, litres, litres_basis, source_sha256, record_ref, import_record_id) VALUES (''s0'', rules_test.sha(''d''), ''OPR-0001'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''SITE-0003'', ''site_total'', ''standalone'', 1, ''stated_without_level'', rules_test.sha(''a''), ''r'', rules_test.rec(''OPR-0001''))');
-- Rules 8, 12, 9: meters and assumptions, generator-only (decision 6) ---------------------------
\set mt 'INSERT INTO fm.runtime_meter (meter_id, operator_uid, holder_uid, source_label, asset_uid, mapping_status) VALUES '
SELECT rules_test.ok('rule 8: a confirmed meter on a placed generator',
:'mt' || '(''MTR-A-1'', ''OPR-0001'', ''SITE-0001'', ''Genset 1'', ''AST-A-GEN'', ''confirmed'')');
SELECT rules_test.no('rule 8: a meter on an engine is refused', 'is not a generator placed',
:'mt' || '(''MTR-A-E'', ''OPR-0001'', ''SITE-0001'', ''Engine'', ''AST-A-ENG'', ''confirmed'')');
SELECT rules_test.no('rule 8: a meter on a tank is refused', 'is not a generator placed',
:'mt' || '(''MTR-A-T'', ''OPR-0001'', ''SITE-0001'', ''Tank'', ''AST-A-T1'', ''proposed'')');
SELECT rules_test.ok('rule 8: an unmapped counter names no asset',
:'mt' || '(''MTR-A-2'', ''OPR-0001'', ''SITE-0001'', ''R.H'', NULL, ''unmapped'')');
INSERT INTO fm.meter_epoch (meter_id, operator_uid, epoch_no, start_reason, continuity) VALUES
('MTR-A-1', 'OPR-0001', 1, 'first_seen', 'assumed'),
('MTR-A-2', 'OPR-0001', 1, 'first_seen', 'assumed');
\set rd 'INSERT INTO fm.hour_meter_reading (source_key, assertion_sha256, operator_uid, visit_id, meter_id, epoch_id, source_field, date_basis, hours, reading_state, transcribed_by, source_sha256, record_ref, import_record_id) VALUES '
SELECT rules_test.ok('rules 3, 4: a reading of the visit''s own meter and epoch',
:'rd' || '(''h1'', rules_test.sha(''f''), ''OPR-0001'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''MTR-A-1'', (SELECT epoch_id FROM fm.meter_epoch WHERE meter_id = ''MTR-A-1''), ''current'', ''stated'', 10, ''clear'', ''source_operator'', rules_test.sha(''a''), ''r'', rules_test.rec(''OPR-0001''))');
SELECT rules_test.no('rule 4: a reading''s epoch belongs to its meter', 'does not belong to meter',
:'rd' || '(''h2'', rules_test.sha(''f''), ''OPR-0001'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''MTR-A-1'', (SELECT epoch_id FROM fm.meter_epoch WHERE meter_id = ''MTR-A-2''), ''current'', ''stated'', 10, ''clear'', ''source_operator'', rules_test.sha(''a''), ''r'', rules_test.rec(''OPR-0001''))');
\set ax 'INSERT INTO fm.assumption (code, fingerprint, operator_uid, holder_uid, asset_uid, topic, basis, derived_from_fuel, status, rationale'
SELECT rules_test.ok('rule 12: an idle assumption on a placed generator',
:'ax' || ') VALUES (''A-IDLE'', rules_test.sha(''0''), ''OPR-0001'', ''SITE-0001'', ''AST-A-GEN2'', ''generator_idle'', ''our_assumption'', false, ''proposed'', ''r'')');
SELECT rules_test.no('rule 12: an idle assumption on an engine is refused', 'is not a generator placed',
:'ax' || ') VALUES (''A-IDLE-E'', rules_test.sha(''0''), ''OPR-0001'', ''SITE-0001'', ''AST-A-ENG'', ''generator_idle'', ''our_assumption'', false, ''proposed'', ''r'')');
-- Rule 5: needs a matching review first, or rule 11 refuses before the CHECK is reached
INSERT INTO fm.review (entry_key, operator_uid, subject_kind, subject_key, assertion_sha256, verdict, reviewed_on, reviewer_role, import_batch_id)
VALUES ('R-5', 'OPR-0001', 'assumption', 'A-R5', rules_test.sha('5'), 'accepted', '2026-01-02', 'r', rules_test.batch('OPR-0001'));
SELECT rules_test.no('rule 5: a runtime derived from fuel cannot be accepted', 'assumption_no_runtime_from_fuel',
:'ax' || ', accepted_on, accepted_by_role) VALUES (''A-R5'', rules_test.sha(''5''), ''OPR-0001'', ''SITE-0001'', NULL, ''runtime_hours_per_day'', ''our_assumption'', true, ''accepted'', ''r'', ''2026-01-02'', ''r'')');
SELECT rules_test.ok('rule 5: one not derived from fuel can be accepted',
:'ax' || ', accepted_on, accepted_by_role) VALUES (''A-R5'', rules_test.sha(''5''), ''OPR-0001'', ''SITE-0001'', NULL, ''runtime_hours_per_day'', ''our_assumption'', false, ''accepted'', ''r'', ''2026-01-02'', ''r'')');
SELECT rules_test.no('rule 9: members complete at commit', 'as many counters as covered',
:'ax' || ') VALUES (''A-COV0'', rules_test.sha(''6''), ''OPR-0001'', ''SITE-0001'', NULL, ''meter_coverage'', ''our_assumption'', false, ''proposed'', ''r''); '
'INSERT INTO fm.assumption_member (assumption_id, operator_uid, member_role, meter_id) SELECT assumption_id, ''OPR-0001'', ''counter'', ''MTR-A-2'' FROM fm.assumption WHERE code = ''A-COV0''; '
'SET CONSTRAINTS ALL IMMEDIATE');
SELECT rules_test.ok('rule 9: valid and complete members in the assumption''s own transaction',
:'ax' || ') VALUES (''A-COV'', rules_test.sha(''6''), ''OPR-0001'', ''SITE-0001'', NULL, ''meter_coverage'', ''our_assumption'', false, ''proposed'', ''r''); '
'INSERT INTO fm.assumption_member (assumption_id, operator_uid, member_role, meter_id) SELECT assumption_id, ''OPR-0001'', ''counter'', ''MTR-A-2'' FROM fm.assumption WHERE code = ''A-COV''; '
'INSERT INTO fm.assumption_member (assumption_id, operator_uid, member_role, asset_uid) SELECT assumption_id, ''OPR-0001'', ''covered_generator'', ''AST-A-GEN'' FROM fm.assumption WHERE code = ''A-COV''; '
'SET CONSTRAINTS ALL IMMEDIATE');
SELECT rules_test.no('rule 9: members are written in the same transaction', 'written in the transaction',
'INSERT INTO fm.assumption_member (assumption_id, operator_uid, member_role, asset_uid) SELECT assumption_id, ''OPR-0001'', ''idle_generator'', ''AST-A-GEN2'' FROM fm.assumption WHERE code = ''A-COV''');
SELECT rules_test.no('rule 9: members are immutable', 'members are immutable',
'DELETE FROM fm.assumption_member');
SELECT rules_test.no('rule 9: an engine cannot be a covered member', 'is not a generator placed',
'INSERT INTO fm.assumption (code, fingerprint, operator_uid, holder_uid, topic, basis, derived_from_fuel, status, rationale) '
'SELECT ''A-COV2'', rules_test.sha(''7''), ''OPR-0002'', ''SITE-0002'', ''meter_coverage'', ''our_assumption'', false, ''proposed'', ''r''; '
'INSERT INTO fm.asset (asset_uid, operator_uid, kind, import_record_id) VALUES (''AST-B-ENG'', ''OPR-0002'', ''engine'', rules_test.rec(''OPR-0002'')); '
'INSERT INTO fm.asset_placement (asset_uid, operator_uid, holder_uid, slot_label, occupied_from, installed_on) VALUES (''AST-B-ENG'', ''OPR-0002'', ''SITE-0002'', ''E1'', ''2025-01-01'', ''2025-01-01''); '
'INSERT INTO fm.assumption_member (assumption_id, operator_uid, member_role, asset_uid) SELECT assumption_id, ''OPR-0002'', ''covered_generator'', ''AST-B-ENG'' FROM fm.assumption WHERE code = ''A-COV2''');
-- Rule 7: source documents ---------------------------------------------------------------------
\set sd 'INSERT INTO fm.source_document (sha256, operator_uid, source_family, kind, issuer, title, corpus_path, contains_personal_data, supersedes_sha256) VALUES '
SELECT rules_test.ok('rule 7: a later version in its own family',
:'sd' || '(rules_test.sha(''1''), ''OPR-0001'', ''family-a'', ''operator_workbook'', ''Issuer A'', ''later'', ''a2.xlsx'', false, rules_test.sha(''a''))');
SELECT rules_test.no('rule 7: no supersession across families', 'may supersede only',
:'sd' || '(rules_test.sha(''2''), ''OPR-0001'', ''family-other'', ''operator_workbook'', ''Issuer A'', ''x'', ''x.xlsx'', false, rules_test.sha(''1''))');
SELECT rules_test.no('rule 7: a document cannot be shared with a second customer', 'another customer',
:'sd' || '(rules_test.sha(''a''), ''OPR-0002'', ''family-a'', ''operator_workbook'', ''Issuer A'', ''x'', ''x.xlsx'', false, NULL)');
-- Rule 11: status from a review ------------------------------------------------------------------
SELECT rules_test.no('rule 11: no status without a review', 'needs a review',
'UPDATE fm.stock_observation SET claim_status = ''reviewed'' WHERE source_key = ''stock:SITE-0001:2026-01-01:1''');
SELECT rules_test.no('rule 11: a value is never corrected in place', 'only the status columns',
'UPDATE fm.stock_observation SET litres = 101 WHERE source_key = ''stock:SITE-0001:2026-01-01:1''');
INSERT INTO fm.review (entry_key, operator_uid, subject_kind, subject_key, assertion_sha256, verdict, reviewed_on, reviewer_role, import_batch_id) VALUES
('R-OTHER', 'OPR-0002', 'claim', 'stock:SITE-0001:2026-01-01:1', rules_test.sha('d'), 'reviewed', '2026-01-02', 'r', rules_test.batch('OPR-0002'));
SELECT rules_test.no('rule 11: another customer''s review does not count', 'needs a review',
'UPDATE fm.stock_observation SET claim_status = ''reviewed'' WHERE source_key = ''stock:SITE-0001:2026-01-01:1''');
INSERT INTO fm.review (entry_key, operator_uid, subject_kind, subject_key, assertion_sha256, verdict, reviewed_on, reviewer_role, import_batch_id) VALUES
('R-OTHER', 'OPR-0001', 'claim', 'stock:SITE-0001:2026-01-01:1', rules_test.sha('d'), 'reviewed', '2026-01-02', 'r', rules_test.batch('OPR-0001'));
SELECT rules_test.ok('rule 11: a matching review gives the status; entry ids are per customer',
'UPDATE fm.stock_observation SET claim_status = ''reviewed'' WHERE source_key = ''stock:SITE-0001:2026-01-01:1''');
SELECT rules_test.no('review: an entry id is unique within one customer', 'duplicate key',
'INSERT INTO fm.review (entry_key, operator_uid, subject_kind, subject_key, assertion_sha256, verdict, reviewed_on, reviewer_role, import_batch_id) VALUES (''R-OTHER'', ''OPR-0001'', ''claim'', ''elsewhere'', rules_test.sha(''d''), ''disputed'', ''2026-01-03'', ''r'', rules_test.batch(''OPR-0001''))');
-- Rule 13: identity never changes in place -------------------------------------------------------
SELECT rules_test.no('rule 13: a visit''s date', 'never changes in place', 'UPDATE fm.visit SET visit_on = ''2026-01-02'' WHERE visit_key = ''SITE-0001:2026-01-01:1''');
SELECT rules_test.no('rule 13: a document''s family', 'never changes in place', 'UPDATE fm.source_document SET source_family = ''x'' WHERE sha256 = rules_test.sha(''a'')');
SELECT rules_test.no('rule 13: an asset''s kind', 'never changes in place', 'UPDATE fm.asset SET kind = ''engine'' WHERE asset_uid = ''AST-A-GEN''');
SELECT rules_test.no('rule 13: a meter''s holder', 'never changes in place', 'UPDATE fm.runtime_meter SET holder_uid = ''SITE-0002'' WHERE meter_id = ''MTR-A-1''');
SELECT rules_test.no('rule 13: an epoch''s meter', 'never changes in place', 'UPDATE fm.meter_epoch SET meter_id = ''MTR-A-2'' WHERE meter_id = ''MTR-A-1''');
SELECT rules_test.no('rule 13: a holder''s kind', 'never changes in place', 'UPDATE fm.holder SET kind = ''trailer'' WHERE holder_uid = ''SITE-0001''');
SELECT rules_test.ok('rule 13: a descriptive field may change', 'UPDATE fm.holder SET name = ''Renamed'' WHERE holder_uid = ''SITE-0001''');
CREATE TABLE rules_test.wrong_name (a integer);
CREATE TRIGGER wrong_name BEFORE UPDATE ON rules_test.wrong_name FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('nope');
INSERT INTO rules_test.wrong_name VALUES (1);
SELECT rules_test.no('rule 13: a wrong column name raises, not a silent pass', 'does not have', 'UPDATE rules_test.wrong_name SET a = 2');
-- Rules 14, 15 --------------------------------------------------------------------------------
SELECT rules_test.no('rule 14: an unknown time zone', 'unknown time zone', 'UPDATE fm.operator SET timezone = ''Asia/Nowhere'' WHERE operator_uid = ''OPR-0001''');
SELECT rules_test.no('rule 14: an unknown time zone on a place', 'unknown time zone', 'UPDATE fm.place SET timezone = ''Asia/Nowhere'' WHERE place_uid = ''PLC-0001''');
SELECT rules_test.ok('rule 14: a known time zone', 'UPDATE fm.place SET timezone = ''Asia/Kabul'' WHERE place_uid = ''PLC-0001''');
-- Two customers: a child cannot name another customer's parent -----------------------------------
SELECT rules_test.no('customers: a visit on another''s holder', 'foreign key',
'INSERT INTO fm.visit (visit_key, operator_uid, holder_uid, visit_on, kind) VALUES (''x'', ''OPR-0002'', ''SITE-0001'', ''2026-02-01'', ''survey'')');
SELECT rules_test.no('customers: a claim on another''s visit', 'foreign key',
:'mv' || '(''mx'', rules_test.sha(''e''), ''OPR-0002'', ''ex'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''SITE-0001'', ''delivery'', NULL, NULL, 10, ''stated'', rules_test.sha(''b''), ''r'', rules_test.rec(''OPR-0002''))');
SELECT rules_test.no('customers: a placement of another''s asset', 'foreign key',
'INSERT INTO fm.asset (asset_uid, operator_uid, kind, import_record_id) VALUES (''AST-A-SPARE'', ''OPR-0001'', ''generator'', rules_test.rec(''OPR-0001'')); '
'INSERT INTO fm.asset_placement (asset_uid, operator_uid, holder_uid, slot_label, null_reasons) VALUES (''AST-A-SPARE'', ''OPR-0002'', ''SITE-0002'', ''X'', ''{"installed_on": {}}'')');
SELECT rules_test.no('customers: a meter on another''s holder', 'foreign key',
:'mt' || '(''MTR-X'', ''OPR-0002'', ''SITE-0001'', ''X'', NULL, ''unmapped'')');
SELECT rules_test.no('customers: a reading of another''s meter', 'foreign key',
:'rd' || '(''hx'', rules_test.sha(''f''), ''OPR-0002'', rules_test.visit(''SITE-0001:2026-01-01:1''), ''MTR-A-1'', NULL, ''current'', ''stated'', 10, ''clear'', ''source_operator'', rules_test.sha(''b''), ''r'', rules_test.rec(''OPR-0002''))');
SELECT rules_test.no('customers: a baseline on another''s holder', 'foreign key',
:'bv' || ', status) VALUES (''BL-X2'', ''OPR-0002'', ''SITE-0001'', ''fuel_l_per_calendar_day'', ''x'', ''k'', ''2026-01-01'', ''2026-01-11'', ''BP-1'', true, now(), ''r'', ''proposed'')');
SELECT rules_test.no('customers: an input of another''s baseline', 'foreign key',
'INSERT INTO fm.baseline_input (baseline_id, operator_uid, holder_uid, open_visit_id, close_visit_id, start_on, end_on, included, exclusion_reason, eligibility_at_computation, dependencies, stock_dep_sha256) SELECT b.baseline_id, ''OPR-0002'', ''SITE-0002'', rules_test.visit(''SITE-0002:2026-01-01:1''), rules_test.visit(''SITE-0002:2026-01-01:1''), ''2026-01-01'', ''2026-01-01'', false, ''x'', ''excluded'', ''{}'', ''x'' FROM fm.baseline_version b WHERE b.code = ''BL-2''');
SELECT rules_test.no('customers: a claim citing another''s document', 'foreign key',
:'mv' || '(''my'', rules_test.sha(''e''), ''OPR-0002'', ''ey'', rules_test.visit(''SITE-0002:2026-01-01:1''), ''SITE-0002'', ''delivery'', NULL, NULL, 10, ''stated'', rules_test.sha(''a''), ''r'', rules_test.rec(''OPR-0002''))');
SELECT rules_test.no('customers: a holder standing at another''s place', 'foreign key',
'INSERT INTO fm.holder (holder_uid, operator_uid, kind, import_record_id) VALUES (''SITE-0004'', ''OPR-0002'', ''fixed_installation'', rules_test.rec(''OPR-0002'')); '
'INSERT INTO fm.holder_place (holder_uid, operator_uid, place_uid, valid_from) VALUES (''SITE-0004'', ''OPR-0002'', ''PLC-0001'', ''2030-01-01'')');
\set me 'INSERT INTO fm.fuel_movement (source_key, assertion_sha256, operator_uid, event_key, visit_id, holder_uid, kind, litres, litres_state, unit_price, price_currency, source_sha256, record_ref, import_record_id) VALUES '
SELECT rules_test.ok('customers: one event key under the first customer',
:'me' || '(''shared-a'', rules_test.sha(''e''), ''OPR-0001'', ''shared-event'', rules_test.visit(''SITE-0001:2026-01-11:1''), ''SITE-0001'', ''delivery'', 10, ''stated'', 70, ''AAA'', rules_test.sha(''a''), ''ref-a'', rules_test.rec(''OPR-0001''))');
SELECT rules_test.ok('customers: the same event key under the second, with another kind (rule 10 is per customer)',
:'me' || '(''shared-b'', rules_test.sha(''e''), ''OPR-0002'', ''shared-event'', rules_test.visit(''SITE-0002:2026-01-01:1''), ''SITE-0002'', ''withdrawal'', 20, ''stated'', 90, ''BBB'', rules_test.sha(''b''), ''ref-b'', rules_test.rec(''OPR-0002''))');
SELECT rules_test.ok('customers: and as a delivery',
:'me' || '(''shared-c'', rules_test.sha(''e''), ''OPR-0002'', ''shared-event-2'', rules_test.visit(''SITE-0002:2026-01-01:1''), ''SITE-0002'', ''delivery'', 20, ''stated'', 90, ''BBB'', rules_test.sha(''b''), ''ref-b'', rules_test.rec(''OPR-0002''))');
SELECT rules_test.ok('customers: the first again, under the second''s delivery key',
:'me' || '(''shared-d'', rules_test.sha(''e''), ''OPR-0001'', ''shared-event-2'', rules_test.visit(''SITE-0001:2026-01-11:1''), ''SITE-0001'', ''delivery'', 10, ''stated'', 70, ''AAA'', rules_test.sha(''a''), ''ref-a'', rules_test.rec(''OPR-0001''))');
SELECT rules_test.eq('customers: v_delivery gives each its own price and reference under one event key',
'SELECT string_agg(operator_uid || '':'' || unit_price || '':'' || currency || '':'' || source_ref, '','' ORDER BY operator_uid) FROM public.v_delivery WHERE event_key = ''shared-event-2''',
'OPR-0001:70.00:AAA:ref-a,OPR-0002:90.00:BBB:ref-b');
SELECT rules_test.no('customers: a row''s customer cannot change: holder', 'never changes in place', 'UPDATE fm.holder SET operator_uid = ''OPR-0002'' WHERE holder_uid = ''SITE-0001''');
SELECT rules_test.no('customers: a row''s customer cannot change: review', 'never changes in place', 'UPDATE fm.review SET operator_uid = ''OPR-0002'' WHERE entry_key = ''R-5''');
SELECT rules_test.no('customers: a row''s customer cannot change: batch', 'never changes in place', 'UPDATE fm.import_batch SET operator_uid = ''OPR-0002'' WHERE import_batch_id = rules_test.batch(''OPR-0001'')');
SELECT rules_test.eq('customers: every table but asset_kind has the fixed-customer trigger',
'SELECT string_agg(c.relname, '','' ORDER BY c.relname) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = ''fm'' AND c.relkind = ''r'' AND NOT EXISTS (SELECT 1 FROM pg_trigger t WHERE t.tgrelid = c.oid AND t.tgname = ''operator_fixed'')',
'asset_kind');
SELECT rules_test.eq('customers: v_site gives each holder its customer',
'SELECT string_agg(site_uid || ''='' || operator_uid, '','' ORDER BY site_uid) FROM public.v_site', 'SITE-0001=OPR-0001,SITE-0002=OPR-0002');
SELECT rules_test.eq('customers: labels and programme come from each customer''s record',
'SELECT string_agg(site_code || '':'' || pilot_status, '','' ORDER BY site_uid) FROM public.v_site', 'A1:active,B1:active');
SELECT rules_test.eq('customers: local day uses the place''s zone, else the customer''s',
'SELECT string_agg(holder_uid || '':'' || timezone, '','' ORDER BY holder_uid) FROM fm.v_holder_current', 'SITE-0001:Asia/Kabul,SITE-0002:Asia/Tokyo');
-- A vehicle is both an asset and a holder (PD-18) -------------------------------------------------
INSERT INTO fm.asset (asset_uid, operator_uid, kind, import_record_id) VALUES
('AST-VEH-1', 'OPR-0002', 'vehicle', rules_test.rec('OPR-0002')),
('AST-VEH-T', 'OPR-0002', 'vehicle_tank', rules_test.rec('OPR-0002')),
('AST-VEH-E', 'OPR-0002', 'engine', rules_test.rec('OPR-0002'));
\set hd 'INSERT INTO fm.holder (holder_uid, operator_uid, kind, asset_uid, import_record_id) VALUES '
SELECT rules_test.no('vehicle: a vehicle holder needs its asset', 'holder_check', :'hd' || '(''VEH-0001'', ''OPR-0002'', ''vehicle'', NULL, rules_test.rec(''OPR-0002''))');
SELECT rules_test.no('vehicle: a fixed holder has no asset', 'holder_check', :'hd' || '(''SITE-0009'', ''OPR-0002'', ''fixed_installation'', ''AST-VEH-1'', rules_test.rec(''OPR-0002''))');
SELECT rules_test.no('rule 15: a vehicle holder''s asset is a vehicle', 'is not a vehicle', :'hd' || '(''VEH-0001'', ''OPR-0002'', ''vehicle'', ''AST-VEH-T'', rules_test.rec(''OPR-0002''))');
SELECT rules_test.ok('vehicle: asset and holder', :'hd' || '(''VEH-0001'', ''OPR-0002'', ''vehicle'', ''AST-VEH-1'', rules_test.rec(''OPR-0002''))');
SELECT rules_test.no('rule 15: a vehicle stands at no place', 'not a fixed installation',
'INSERT INTO fm.holder_place (holder_uid, operator_uid, place_uid, valid_from) VALUES (''VEH-0001'', ''OPR-0002'', ''PLC-0002'', ''2026-01-01'')');
SELECT rules_test.ok('vehicle: a tank placed on it',
'INSERT INTO fm.asset_placement (asset_uid, operator_uid, holder_uid, slot_label, occupied_from, installed_on) VALUES (''AST-VEH-T'', ''OPR-0002'', ''VEH-0001'', ''C1'', ''2026-01-01'', ''2026-01-01'')');
SELECT rules_test.ok('vehicle: an engine placed on it',
'INSERT INTO fm.asset_placement (asset_uid, operator_uid, holder_uid, slot_label, occupied_from, installed_on) VALUES (''AST-VEH-E'', ''OPR-0002'', ''VEH-0001'', ''E1'', ''2026-01-01'', ''2026-01-01'')');
SELECT rules_test.no('rule 8: a meter on the vehicle''s engine is refused', 'is not a generator placed',
:'mt' || '(''MTR-V-E'', ''OPR-0002'', ''VEH-0001'', ''Engine'', ''AST-VEH-E'', ''confirmed'')');
SELECT rules_test.ok('vehicle: a visit', 'INSERT INTO fm.visit (visit_key, operator_uid, holder_uid, visit_on, kind) VALUES (''VEH-0001:2026-02-01:1'', ''OPR-0002'', ''VEH-0001'', ''2026-02-01'', ''delivery'')');
SELECT rules_test.ok('vehicle: a delivery on its visit',
:'mv' || '(''m-veh'', rules_test.sha(''e''), ''OPR-0002'', ''e-veh'', rules_test.visit(''VEH-0001:2026-02-01:1''), ''VEH-0001'', ''delivery'', NULL, NULL, 200, ''stated'', rules_test.sha(''b''), ''r'', rules_test.rec(''OPR-0002''))');
SELECT rules_test.eq('vehicle: the delivery reaches v_delivery with its customer',
'SELECT operator_uid || '':'' || delivered_l FROM public.v_delivery WHERE site_uid = ''VEH-0001''', 'OPR-0002:200.0');
SELECT rules_test.eq('vehicle: v_site lists it, with no place', 'SELECT coalesce(province, ''no place'') FROM public.v_site WHERE site_uid = ''VEH-0001''', 'no place');
-- Placement: in or on a holder, or at a place, exactly one -----------------------------------------
\set pl 'INSERT INTO fm.asset_placement (asset_uid, operator_uid, holder_uid, place_uid, slot_label, occupied_from, installed_on) VALUES '
INSERT INTO fm.asset (asset_uid, operator_uid, kind, import_record_id) VALUES
('AST-B-T1', 'OPR-0002', 'tank', rules_test.rec('OPR-0002')), ('AST-B-T2', 'OPR-0002', 'tank', rules_test.rec('OPR-0002'));
SELECT rules_test.no('placement: not both', 'asset_placement_check', :'pl' || '(''AST-B-T1'', ''OPR-0002'', ''SITE-0002'', ''PLC-0002'', ''P1'', ''2026-01-01'', ''2026-01-01'')');
SELECT rules_test.no('placement: not neither', 'asset_placement_check', :'pl' || '(''AST-B-T1'', ''OPR-0002'', NULL, NULL, ''P1'', ''2026-01-01'', ''2026-01-01'')');
SELECT rules_test.ok('placement: at a place', :'pl' || '(''AST-B-T1'', ''OPR-0002'', NULL, ''PLC-0002'', ''P1'', ''2026-01-01'', ''2026-01-01'')');
SELECT rules_test.no('placement: one slot at a place at a time', 'conflicting key', :'pl' || '(''AST-B-T2'', ''OPR-0002'', NULL, ''PLC-0002'', ''P1'', ''2026-06-01'', ''2026-06-01'')');
SELECT rules_test.no('placement: one slot on a holder at a time', 'conflicting key', :'pl' || '(''AST-B-T2'', ''OPR-0002'', ''SITE-0002'', NULL, ''GEN-01'', ''2026-06-01'', ''2026-06-01'')');
SELECT rules_test.no('placement: an asset is in one place at a time', 'conflicting key', :'pl' || '(''AST-B-T1'', ''OPR-0002'', ''SITE-0002'', NULL, ''P2'', ''2026-06-01'', ''2026-06-01'')');
SELECT rules_test.eq('placement: a place-held tank does not break the views',
'SELECT count(*)::text FROM public.v_spec_reference WHERE asset_uid = ''AST-B-T1''', '0');
-- Licences -------------------------------------------------------------------------------------
\set li 'INSERT INTO licence.licence (operator_uid, plan_code, payment_term, valid_from, valid_until, grace_until, status, contract_ref, created_by) VALUES '
SELECT rules_test.eq('licence: PD-19''s prices are seeded',
'SELECT string_agg(payment_term || ''='' || price, '','' ORDER BY payment_term) FROM licence.plan_price WHERE plan_code = ''standard''', 'annual=15.00,monthly=17.00');
SELECT rules_test.eq('licence: no customer''s licence is seeded', 'SELECT count(*)::text FROM licence.licence', '0');
SELECT rules_test.eq('licence: seeds are not in the log', 'SELECT count(*)::text FROM licence.licence_event', '0');
SELECT rules_test.ok('licence: a first licence', :'li' || '(''OPR-0001'', ''pilot'', ''annual'', ''2026-01-01'', ''2026-12-31'', ''2027-01-30'', ''active'', ''agreement'', ''our admin'')');
SELECT rules_test.no('licence: overlapping dates refused', 'conflicting key', :'li' || '(''OPR-0001'', ''standard'', ''annual'', ''2026-12-31'', ''2027-12-31'', ''2028-01-30'', ''active'', ''agreement'', ''our admin'')');
SELECT rules_test.ok('licence: a renewal may start during the old grace', :'li' || '(''OPR-0001'', ''standard'', ''annual'', ''2027-01-01'', ''2027-12-31'', ''2028-01-30'', ''active'', ''agreement'', ''our admin'')');
SELECT rules_test.ok('licence: another customer may share the dates', :'li' || '(''OPR-0002'', ''standard'', ''monthly'', ''2026-01-01'', ''2026-12-31'', ''2027-01-30'', ''active'', ''agreement'', ''our admin'')');
SELECT rules_test.no('licence: grace at least until expiry', 'licence_check', :'li' || '(''OPR-0002'', ''standard'', ''annual'', ''2028-01-01'', ''2028-12-31'', ''2028-12-30'', ''active'', ''agreement'', ''our admin'')');
SELECT rules_test.no('licence: an unknown customer', 'foreign key', :'li' || '(''OPR-0009'', ''standard'', ''annual'', ''2028-01-01'', ''2028-12-31'', ''2029-01-30'', ''active'', ''agreement'', ''our admin'')');
SELECT rules_test.eq('licence: every change writes an event', 'SELECT count(*)::text FROM licence.licence_event WHERE table_name = ''licence'' AND action = ''INSERT''', '3');
SELECT rules_test.ok('licence: a suspension', 'UPDATE licence.licence SET status = ''suspended'' WHERE operator_uid = ''OPR-0002''');
SELECT rules_test.eq('licence: the event holds the old and new row',
'SELECT old_row->>''status'' || ''>'' || (new_row->>''status'') FROM licence.licence_event WHERE action = ''UPDATE''', 'active>suspended');
SELECT rules_test.no('licence: the log is never changed', 'never changed', 'DELETE FROM licence.licence_event');
-- Result ------------------------------------------------------------------------------------------
\o
\pset format unaligned
\pset tuples_only on
SELECT CASE WHEN passed THEN 'ok ' ELSE 'FAIL ' END || label || CASE WHEN NOT passed THEN ' - ' || coalesce(detail, '') ELSE '' END
FROM rules_test.result ORDER BY n;
SELECT 'rules: ' || count(*) || ' checks, ' || count(*) FILTER (WHERE NOT passed) || ' failed' FROM rules_test.result;
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM rules_test.result WHERE NOT passed) THEN RAISE EXCEPTION 'rule tests failed'; END IF;
END $$;
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Applies the migrations to a throw-away database and runs the rule tests. Synthetic data only.
# Needs a running fuel-db container with this repository's roles (fuel_owner, fuel_ro), e.g. the
# local stack: FUEL_DB_CONTAINER=fmdb-scratch-db stacks/fuel-monitoring/fuel-db/tests/run.sh
# Never point it at the server: it creates a role and a database.
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
container=${FUEL_DB_CONTAINER:?set FUEL_DB_CONTAINER to a local fuel-db container}
db=fuel_rules_$$
loader=fuel_rules_loader
psql_as() {
local user=$1
shift
docker exec -i "$container" psql -X -q -v ON_ERROR_STOP=1 -U "$user" "$@"
}
cleanup() {
psql_as postgres -d postgres -c "DROP DATABASE IF EXISTS $db;" >/dev/null
}
trap cleanup EXIT
psql_as postgres -d postgres -c "CREATE DATABASE $db;" >/dev/null
psql_as postgres -d postgres >/dev/null <<SQL
DO \$\$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '$loader') THEN
CREATE ROLE $loader LOGIN;
GRANT fuel_owner TO $loader WITH INHERIT FALSE, SET TRUE;
ALTER ROLE $loader SET role TO 'fuel_owner';
END IF;
END \$\$;
ALTER DATABASE $db OWNER TO fuel_owner;
GRANT CONNECT ON DATABASE $db TO fuel_ro;
SQL
psql_as postgres -d "$db" >/dev/null <<SQL
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO fuel_ro;
ALTER DEFAULT PRIVILEGES FOR ROLE fuel_owner IN SCHEMA public GRANT SELECT ON TABLES TO fuel_ro;
SQL
for file in "$here"/../migrations/*.sql; do
psql_as "$loader" -d "$db" <"$file" >/dev/null
done
echo "migrations applied: $(cd "$here/../migrations" && echo *.sql)"
psql_as "$loader" -d "$db" <"$here/rules.sql"
psql_as fuel_ro -d "$db" <"$here/read_only.sql" 2>&1 | sed 's/^psql:[^ ]* NOTICE: //'
+3 -3
View File
@@ -6,12 +6,12 @@
FROM apache/superset:6.1.0@sha256:16b50bbef6648912a79e3293d418fefa743ce555d36c5af6b85d859119ed7f88 FROM apache/superset:6.1.0@sha256:16b50bbef6648912a79e3293d418fefa743ce555d36c5af6b85d859119ed7f88
USER root USER root
# The 6.1.0 image ships neither an OAuth client nor the Postgres and Trino # The 6.1.0 image ships neither an OAuth client nor the Postgres driver.
# drivers.
RUN uv pip install --no-cache --python /app/.venv/bin/python \ RUN uv pip install --no-cache --python /app/.venv/bin/python \
authlib==1.9.1 psycopg2-binary==2.9.13 "trino[sqlalchemy]==0.340.0" authlib==1.9.1 psycopg2-binary==2.9.13
COPY --from=insights --chown=superset:superset superset_config.py petrolyst_security.py /app/pythonpath/ COPY --from=insights --chown=superset:superset superset_config.py petrolyst_security.py /app/pythonpath/
COPY --from=insights --chown=superset:superset roles/ /app/petrolyst-roles/
COPY --from=insights --chown=superset:superset assets/branding/ /app/superset/static/assets/branding/ COPY --from=insights --chown=superset:superset assets/branding/ /app/superset/static/assets/branding/
COPY --from=insights patches/ /app/patches/petrolyst/ COPY --from=insights patches/ /app/patches/petrolyst/
# Stop at the first patch or verify that fails, so a later success cannot hide # Stop at the first patch or verify that fails, so a later success cannot hide
+5 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# superset-init: migrate the metadata database, sync roles and permissions, and # superset-init: migrate the metadata database, sync roles and permissions, and
# register the Trino connection. It is copied into the Superset image, so a # register the connection to fuel-db. It is copied into the Superset image, so a
# change here changes the image tag; deploy/deploy.sh runs it on a fresh install # change here changes the image tag; deploy/deploy.sh runs it on a fresh install
# and whenever that tag changes. Every step is idempotent. Unlike the # and whenever that tag changes. Every step is idempotent. Unlike the
# upstream docker-init.sh it never creates a local admin: users come from # upstream docker-init.sh it never creates a local admin: users come from
@@ -9,6 +9,8 @@ set -euo pipefail
superset db upgrade superset db upgrade
superset init superset init
# No password here: superset_config.py reads it from /run/secrets at connect time.
superset set-database-uri \ superset set-database-uri \
--database_name "Fuel (Trino)" \ --database_name "Fuel" \
--uri "trino://superset@trino:8080/fuel" --uri "postgresql+psycopg2://fuel_ro@fuel-db:5432/fuel"
@@ -1,3 +0,0 @@
# Analytics never writes through Trino. fuel-db's trino_ro role is read-only as
# well; this is the second lock.
access-control.name=read-only
@@ -1,5 +0,0 @@
# The fuel-monitoring operational store (service fuel-db), read-only.
connector.name=postgresql
connection-url=jdbc:postgresql://fuel-db:5432/fuel
connection-user=trino_ro
connection-password=${ENV:FUEL_DB_TRINO_PASSWORD}
@@ -1,14 +0,0 @@
# Single node: coordinator and worker in one process. Reachable only on the
# backend network (Superset); no port is published and Traefik has no route.
coordinator=true
node-scheduler.include-coordinator=true
http-server.http.port=8080
discovery.uri=http://localhost:8080
catalog.management=static
# Memory bounds. The container is capped at 3g (compose.yaml) and jvm.config
# gives the heap 75 % of it (~2.25 GB); 1 GB for queries plus 512 MB headroom
# leaves room for the rest of the heap.
query.max-memory=1GB
query.max-memory-per-node=1GB
memory.heap-headroom-per-node=512MB
@@ -1,14 +0,0 @@
-server
-agentpath:/usr/lib/trino/bin/libjvmkill.so
-XX:InitialRAMPercentage=75
-XX:MaxRAMPercentage=75
-XX:G1HeapRegionSize=32M
-XX:+ExplicitGCInvokesConcurrent
-XX:+HeapDumpOnOutOfMemoryError
-XX:+ExitOnOutOfMemoryError
-XX:-OmitStackTraceInFastThrow
-XX:ReservedCodeCacheSize=256M
-XX:PerMethodRecompilationCutoff=10000
-XX:PerBytecodeRecompilationCutoff=10000
-Djdk.attach.allowAttachSelf=true
-Djdk.nio.maxCachedBufferSize=2000000
@@ -1 +0,0 @@
io.trino=INFO
@@ -1,3 +0,0 @@
node.environment=petrolyst
node.data-dir=/data/trino
node.id=${ENV:HOSTNAME}
+2 -2
View File
@@ -69,7 +69,7 @@ services:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5 image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5
command: command:
- --provider=keycloak-oidc - --provider=keycloak-oidc
- --oidc-issuer-url=${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/thedigitalbridge} - --oidc-issuer-url=${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/petrolyst}
- --client-id=petrolyst - --client-id=petrolyst
- --client-secret-file=/run/secrets/keycloak_petrolyst_client_secret - --client-secret-file=/run/secrets/keycloak_petrolyst_client_secret
- --cookie-secret-file=/run/secrets/oauth2_proxy_cookie_secret - --cookie-secret-file=/run/secrets/oauth2_proxy_cookie_secret
@@ -114,6 +114,6 @@ volumes:
secrets: secrets:
keycloak_petrolyst_client_secret: keycloak_petrolyst_client_secret:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/shared/keycloak_petrolyst_client_secret file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/shared/keycloak_petrolyst_realm_client_secret
oauth2_proxy_cookie_secret: oauth2_proxy_cookie_secret:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/traefik/oauth2_proxy_cookie_secret file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/traefik/oauth2_proxy_cookie_secret