Compare commits

...
13 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
Nasim SadatandClaude Opus 5.5 97284052c1 atoma dashboards: eligible needs >= 30 matched days; modelled L/h as table B11
SPEC.md follows the owner's decisions of 24 Oct. The "eligible" baseline state
asks for >= 30 matched days (was 60). Modelled L/h is in v1 as table B11 on
Baseline & Evidence (v_modelled_lph): titled as modelled from an assumed
runtime, not measured, and never in S1, the map or the M3 sites list; the
datasets table and acceptance check 4 include it. The migrations are
unchanged: the threshold is applied by the loader's baseline propose, not by
SQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 11:09:09 +02:00
Nasim SadatandClaude Opus 5.5 bc27bf2356 deploy: remove the CI pipeline; deploys are manual
No runner was ever active, so the pipeline never ran. Removes
.gitea/workflows/deploy.yml and the CI key and runner IP from
host/bootstrap.sh (a re-run removes the key an earlier bootstrap installed).
The README makes "Manual deploy" the one documented procedure: commit on main,
shellcheck in Docker (-S warning; the pipeline's lint step), git archive over
root SSH, deploy.sh as user deploy, the outside checks, then push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 09:58:22 +02:00
Nasim SadatandClaude Opus 5.5 bf96edc828 fuel-db: schema fm, derivations and BI views (draft, not applied); ATOMA dashboard spec
Adds stacks/fuel-monitoring/fuel-db/migrations/0001-0003 with their README, and
petrolyst-insights/dashboards/atoma/SPEC.md. Nothing applies the migrations:
deploy.sh never touches fuel-db, and a person applies them by hand in phase P3.

Verified in scratch Docker (timescaledb 2.31.0-pg17 with this repository's
01-roles.sh, trino 484 with its trino/etc): 150 SQL checks pass; the 18 views
have 303 columns with identical types in PostgreSQL and Trino; fm cannot be
read through Trino. Codex (gpt-6-astra) inspected the change in four rounds;
the round-4 fixes are committed without a further inspection, on the owner's
instruction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 09:58:14 +02:00
57 changed files with 6607 additions and 389 deletions

No files matched your search

-57
View File
@@ -1,57 +0,0 @@
# Every push to main deploys everything: Traefik first, then each stack under
# stacks/. The runner only uploads; the server validates, deploys and
# smoke-tests (deploy/deploy.sh), so this job needs no Docker of its own.
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
# Serializes CI runs; deploy.sh's host lock also covers manual rollbacks.
concurrency:
group: deploy-petrolyst
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
steps:
- uses: actions/checkout@v4
- name: Lint shell scripts
run: |
if ! command -v shellcheck >/dev/null; then
SUDO=$(command -v sudo || true)
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq shellcheck
fi
shellcheck -x host/*.sh deploy/*.sh stacks/*/superset/init.sh stacks/*/fuel-db/init/*.sh
- name: Install the deploy key
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
run: |
install -d -m 0700 "$HOME/.ssh"
printf '%s\n' "$DEPLOY_SSH_KEY" > "$HOME/.ssh/deploy_key"
chmod 0600 "$HOME/.ssh/deploy_key"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts"
command -v rsync >/dev/null || { SUDO=$(command -v sudo || true); $SUDO apt-get install -y -qq rsync; }
- name: Upload and deploy
run: |
ssh_cmd="ssh -i $HOME/.ssh/deploy_key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts"
# A fresh directory per run; only deploy.sh, under the host lock,
# turns it into releases/<sha>. --omit-dir-times: the upload's age
# must be the server's, because retention prunes uploads by mtime.
incoming="/opt/petrolyst/incoming/${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}-$(date -u +%s)"
rsync -az --omit-dir-times --exclude .git -e "$ssh_cmd" ./ "deploy@${DEPLOY_HOST}:${incoming}/"
$ssh_cmd "deploy@${DEPLOY_HOST}" "${incoming}/deploy/deploy.sh" "$GITHUB_SHA" "$incoming"
- name: Check from outside
run: |
test "$(curl -fsS --max-time 20 https://atoma.petrolyst.com/health)" = OK
curl -fsS --max-time 20 -o /dev/null -w '%{http_code}\n' https://traefik.petrolyst.com/ | grep -qx 302
+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
+175 -86
View File
@@ -1,34 +1,37 @@
# petrolyst-infra
Everything that runs on the petrolyst server (109.199.127.84, Ubuntu 24.04), deployed on every
push to `main`:
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
https://traefik.petrolyst.com, behind Keycloak.
- **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.
**Deploys are manual.** A person deploys a clean commit of `main` from the Mac ("Manual deploy"
under Operating). There is no CI runner, and a push to Gitea deploys nothing.
Customization and deployment are separate, as with media-insights and media-intelligence-docker:
| Folder | Holds |
|---|---|
| `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 |
| `stacks/fuel-monitoring/` | Compose stack, Superset Dockerfile, Trino config and catalog, fuel-db roles |
| `deploy/` | `deploy.sh` (run by CI), `rollback.sh` (manual), `lib.sh` (shared) |
| `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) |
| `host/` | one-time server setup: `bootstrap.sh`, `secrets-init.sh` |
| `keycloak/` | the `petrolyst` client for Keycloak's partial import |
| `.gitea/workflows/deploy.yml` | the pipeline |
| `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
`/opt/petrolyst/secrets/`; CI holds only the SSH deploy key.
`/opt/petrolyst/secrets/`. A deploy needs only root SSH access to the server (`ssh petrolyst`).
## How a deploy works
1. A push to `main` (or a manual run) starts `.gitea/workflows/deploy.yml` on the act_runner. It
lints the scripts, rsyncs the checkout to a fresh `/opt/petrolyst/incoming/<sha>-<run>/`, and
calls `deploy/deploy.sh <sha> <that dir>` over SSH as user `deploy`.
1. A person runs "Manual deploy" (Operating, below) from a clean checkout of `main`: the scripts
are linted, `git archive` sends exactly that commit to a fresh
`/opt/petrolyst/incoming/<sha>-manual-<time>/`, and `deploy/deploy.sh <sha> <that dir>` runs
there as user `deploy` (root hands over with `sudo -u deploy`).
2. `deploy.sh` takes the host lock `/opt/petrolyst/deploy.lock`, which it shares with
`rollback.sh`, and keeps it until it exits.
3. It finalizes the upload. A new commit is moved to `releases/<sha>`, and the release is made
@@ -36,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`:
- `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.
- `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.
5. Preflight, before anything live changes:
- every secret and env file exists;
- every secret file exists;
- `petrolyst-insights/` holds no deployment files;
- `docker compose config` passes for every stack;
- the Superset image is built if it is new;
@@ -50,30 +53,30 @@ No credential is in this repository. Secrets exist only on the server, under
- 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
`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.
8. Smoke tests. A failure turns CI red 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.
- `/login/` leads to Keycloak with `client_id=petrolyst` and PKCE.
- 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:
- 5 releases;
- backups for 30 days;
- the 3 newest Superset images, plus any image that a kept release or backup names;
- uploads older than a day.
The runner then checks both hosts from outside.
The manual deploy then checks both hosts from outside.
## Server paths
| Path | Owner | What |
|---|---|---|
| `/opt/petrolyst/infra` | deploy | live tree (the promoted release) |
| `/opt/petrolyst/incoming/<sha>-<run>` | deploy | one CI 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/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/state/` | deploy | last deployed SHA and Superset image tag; `rollback-in-progress` during a migration rollback |
@@ -81,34 +84,27 @@ The runner then checks both hosts from outside.
### 1. Gitea
1. Create the private repository `Petrolyst/petrolyst-infra` and push this repository's
`main` to it. Enable Actions under Settings → Repository → Units.
2. Runner. Check Site/Org Administration → Actions → Runners. If none is registered, run
[Gitea Runner](https://docs.gitea.com/runner/installation/docker/) as the container
`docker.io/gitea/runner:5.2.2-dind` (privileged, with `/data` and `/var/lib/docker` volumes),
for example on the Gitea host (164.68.121.117):
- register it as an **organization runner** for `Petrolyst`: take the token from
Settings → Actions → Runners → Create new Runner;
- set `GITEA_RUNNER_LABELS=ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest`;
- note its egress IPv4 (`curl -4 -s https://ifconfig.me`).
3. Generate a deploy key on your Mac, used only for this:
`ssh-keygen -t ed25519 -N '' -C petrolyst-ci -f petrolyst-ci`
4. Add repository secrets (Settings → Actions → Secrets):
- `DEPLOY_SSH_KEY`: contents of `petrolyst-ci` (the private key)
- `DEPLOY_HOST`: `109.199.127.84`
- `DEPLOY_KNOWN_HOSTS`: output of `ssh-keyscan -t ed25519 109.199.127.84`. Compare its
fingerprint (`ssh-keygen -lf <file>`) with `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`
run on the server.
Create the private repository `Petrolyst/petrolyst-infra` and push this repository's `main` to
it. Gitea only keeps the history: deploys are manual, so it needs no runner, no Actions and no
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:
- the confidential client `petrolyst`: standard flow only, PKCE S256, redirect URIs for both
hosts;
- the confidential client `petrolyst`, for Superset and Traefik: standard flow only, PKCE S256,
redirect URIs for both hosts;
- 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).
**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
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:
@@ -118,38 +114,50 @@ The runner then checks both hosts from outside.
- Clients → petrolyst → Advanced → Authentication flow overrides → Browser flow:
`petrolyst-mfa`.
Result: every sign-in to a Petrolyst app 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
Superset and the dashboard.
4. Give people roles: Users → (user) → Role mapping → Assign role → filter by clients →
`petrolyst`. Group mappings work too.
Result: every sign-in to Superset or Traefik asks for password and OTP, and a user without OTP
enrols first. There is no silent SSO between them. Client `petrolyst-app` keeps the realm's own
flow: customers' users sign in with a password only, the owner's choice "for now" (PD-21).
4. **A customer is a top-level group.** Groups → Create group, named as the customer's
`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
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`)
The server needs only the two host scripts; it never gets Gitea credentials.
```bash
# on the Mac, in this repository
scp host/bootstrap.sh host/secrets-init.sh petrolyst-ci.pub petrolyst:/root/
scp host/bootstrap.sh host/secrets-init.sh petrolyst:/root/
ssh petrolyst
# on the server, as root
/root/bootstrap.sh --ci-key /root/petrolyst-ci.pub --runner-ip <runner egress IP>
/root/bootstrap.sh
/root/secrets-init.sh # generates every secret; reports the missing Keycloak one
/root/secrets-init.sh keycloak-secret # paste the secret from Keycloak step 2
```
No runner yet? Pass `--runner-ip 127.0.0.1`. That installs the CI key but leaves it unusable from
outside. Deploy manually until a runner exists (see "Manual deploy" below). When the runner is
running, run `bootstrap.sh` again with its egress IP.
`bootstrap.sh` does the following:
- installs Docker from Docker's apt repository;
- adds 4 GB swap;
- turns on ufw for 22, 80 and 443 only;
- turns off SSH password login and root password login;
- creates the `deploy` user, whose key works only from the runner IP;
- creates the `deploy` user, which runs `deploy.sh`. It has no SSH key and is reached only
through `sudo -u deploy` from root; a re-run removes the CI key an earlier version installed;
- creates `/opt/petrolyst` and the Docker network `proxy`.
**Before closing your root session**, check from a second terminal that `ssh petrolyst` still
@@ -157,8 +165,7 @@ works.
### 4. First deploy
Run the workflow manually (Actions → deploy → Run workflow) or push to `main`. Without a runner,
use "Manual deploy" below. The first run builds the Superset image and takes the fresh-install
Use "Manual deploy" below. The first run builds the Superset image and takes the fresh-install
path. Let's Encrypt needs about a minute
for the two certificates; the smoke tests wait up to two minutes.
@@ -169,30 +176,50 @@ becomes the first Superset admin; there is no local admin account.
| Task | How |
|---|---|
| Deploy | push to `main` (without a runner: "Manual deploy" below) |
| Re-deploy a kept release | `sudo -u deploy /opt/petrolyst/infra/deploy/deploy.sh <sha>` |
| Deploy | "Manual deploy" below, from a clean `main` |
| 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 |
| 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` |
| Logs | `docker compose -p fuel-monitoring logs -f superset` (or `-p traefik`) |
| Superset break-glass | `docker exec -it fuel-monitoring-superset-1 superset fab ...` |
| Logs | `docker compose -p petrolyst logs -f superset` (or `-p traefik`) |
| Superset break-glass | `docker exec -it petrolyst-superset-1 superset fab ...` |
**Manual deploy** (no runner): commit first, so that `git status` is clean. Then run this on the
Mac, in this repository:
**Manual deploy**, the only way to deploy (there is no CI runner). On the Mac, in this
repository:
```bash
sha=$(git rev-parse HEAD)
dir=/opt/petrolyst/incoming/$sha-manual-$(date -u +%s)
git archive --format=tar "$sha" |
ssh petrolyst "install -d -o deploy -g deploy -m 0755 $dir && tar -x --no-same-owner -C $dir && chown -R deploy:deploy $dir"
ssh petrolyst "sudo -u deploy $dir/deploy/deploy.sh $sha $dir"
```
1. Commit on `main`, so that `git status` is clean. The live commit is in
`/opt/petrolyst/state/deployed-sha` (`ssh petrolyst cat /opt/petrolyst/state/deployed-sha`).
2. Lint the shell scripts. Warnings and errors must be zero; the info notes on the deliberate
`test && test || die` guards in `deploy/lib.sh` (SC2015) are filtered out:
`git archive` sends exactly the commit, never uncommitted or ignored files such as the deploy
key. The server then runs the same `deploy.sh` that CI runs, under the same lock. A later push
queues a CI run that waits for a runner. Cancel stale waiting runs under Actions before you
register one.
```bash
docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:v0.10.0 -S warning -x \
host/*.sh deploy/*.sh stacks/*/superset/init.sh stacks/*/fuel-db/init/*.sh
```
3. Deploy:
```bash
sha=$(git rev-parse HEAD)
dir=/opt/petrolyst/incoming/$sha-manual-$(date -u +%s)
git archive --format=tar "$sha" |
ssh petrolyst "install -d -o deploy -g deploy -m 0755 $dir && tar -x --no-same-owner -C $dir && chown -R deploy:deploy $dir"
ssh petrolyst "sudo -u deploy $dir/deploy/deploy.sh $sha $dir"
```
4. Check both hosts from outside:
```bash
test "$(curl -fsS --max-time 20 https://atoma.petrolyst.com/health)" = OK && echo atoma ok
curl -fsS --max-time 20 -o /dev/null -w '%{http_code}\n' https://traefik.petrolyst.com/ # 302
```
5. Push `main` to Gitea, so that the repository matches what is live.
`git archive` sends exactly the commit, never uncommitted or ignored files. The server runs
`deploy.sh` under the host lock it shares with `rollback.sh`, and records the live commit in
`/opt/petrolyst/state/deployed-sha`.
**Rollback** (`deploy/rollback.sh`, manual, under the same lock as deploys):
@@ -220,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
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
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
`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
CREATE ROLE <loader> LOGIN PASSWORD '<password>';
@@ -233,9 +260,74 @@ GRANT fuel_owner TO <loader> WITH INHERIT FALSE, SET TRUE;
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
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
external network `proxy`, and set `traefik.*` labels with a unique router name and
@@ -263,7 +355,6 @@ Docker Desktop, with a scratch `/opt/petrolyst`. Production differences in that
Results:
- The fresh install took the init path.
- 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 change to `superset/init.sh` alone did the same, and the changed script ran.
- A same-SHA upload with different content was refused.
@@ -280,19 +371,17 @@ Results:
- Retention pruned read-only releases.
- 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`.
- In `fuel-db`, a loader that created a table as its own role left it unreadable for `trino_ro`.
With the "Data loaders" recipe, tables (hypertables too) belonged to `fuel_owner` and Trino
could read them. After `SET ROLE NONE`, the loader could not create anything.
- In `fuel-db`, a loader that created a table as its own role left it unreadable for the read-only
role. With the "Data loaders" recipe, tables (hypertables too) belonged to `fuel_owner` and that
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`
and PKCE S256.
- TLS 1.1 and unknown SNI were refused, and HTTP redirected to HTTPS.
- Trino and `trino_ro` both refused writes.
Not verified locally:
- Let's Encrypt issuance;
- a real Keycloak sign-in, OTP and logout (they need the client from setup step 2);
- the theme as rendered in a browser;
- the Gitea runner.
- the theme as rendered in a browser.
## Open items
+7 -7
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Server-side deploy, run as the non-root deploy user:
# deploy.sh <sha> <incoming-dir> CI: finalize the upload as releases/<sha>, then deploy it
# deploy.sh <sha> <incoming-dir> manual deploy: finalize the upload as releases/<sha>, then deploy it
# deploy.sh <sha> re-deploy a release that is already finalized
#
# Order: lock, finalize, artifacts, preflight, image build and downgrade guard
@@ -11,7 +11,7 @@ set -euo pipefail
# shellcheck source=deploy/lib.sh
. "$(dirname "$(readlink -f "$0")")/lib.sh"
# finalize_release <sha> <upload>: CI never writes into releases/. A new SHA is
# finalize_release <sha> <upload>: an upload never writes into releases/. A new SHA is
# moved in atomically; a re-run of a deployed SHA must match it byte for byte
# and leaves the finalized release, ARTIFACTS included, untouched.
finalize_release() {
@@ -44,7 +44,7 @@ resolve_artifacts() {
fi
chmod -R a-w "$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
@@ -100,10 +100,10 @@ retention() {
rm -rf "$dir"
log "removed release ${dir##*/}"
done < <(find "$RELEASES" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -rn | tail -n +6 | cut -d' ' -f2-)
# Uploads older than a day were never finalized (interrupted CI runs). Their
# mtime is the server's (the workflow's rsync omits directory times), and a
# CI job lives at most 45 minutes, so an upload being sent or waiting for the
# lock is never this old.
# Uploads older than a day were never finalized (interrupted deploys). The
# upload directory is created on the server when the upload starts, and its
# mtime is the server's, so an upload being sent or waiting for the lock is
# never this old.
while IFS= read -r -d '' dir; do
chmod -R u+w "$dir"
rm -rf "$dir"
+57 -23
View File
@@ -17,11 +17,13 @@ ROLLBACK_MARK=$STATE/rollback-in-progress
ATOMA_HOST=${ATOMA_HOST:-atoma.petrolyst.com}
TRAEFIK_HOST=${TRAEFIK_HOST:-traefik.petrolyst.com}
KEYCLOAK_REALM_URL=${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/thedigitalbridge}
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=(docker --host unix:///var/run/docker.sock)
@@ -33,19 +35,19 @@ SUPERSET_INPUTS=(
stacks/fuel-monitoring/superset/init.sh
petrolyst-insights/superset_config.py
petrolyst-insights/petrolyst_security.py
petrolyst-insights/roles
petrolyst-insights/assets/branding
petrolyst-insights/patches
)
# 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=(
secrets/shared/keycloak_petrolyst_client_secret
secrets/traefik/oauth2_proxy_cookie_secret
secrets/fuel-monitoring/superset_secret_key
secrets/fuel-monitoring/superset_db_password
secrets/fuel-monitoring/fuel_db_password
secrets/fuel-monitoring/fuel_db_trino_password
env/fuel-monitoring.env
secrets/fuel-monitoring/fuel_db_ro_password
)
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
@@ -57,8 +59,8 @@ die() { log "ERROR: $*"; exit 1; }
# is absolute, so nothing needs the caller's directory.
cd /
# acquire_lock <seconds>: deploy.sh and rollback.sh share this lock, so a CI
# deploy and a manual rollback never run at the same time. Held until exit.
# acquire_lock <seconds>: deploy.sh and rollback.sh share this lock, so a
# deploy and a rollback never run at the same time. Held until exit.
acquire_lock() {
exec 9>>"$LOCK_FILE"
flock -w "$1" 9 || die "another deploy or rollback holds $LOCK_FILE (waited $1 s)"
@@ -82,7 +84,6 @@ hash_inputs() {
compute_artifacts() {
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.
printf 'TRAEFIK_CONFIG_HASH=%s\n' "$(hash_inputs "$1" traefik/static)"
}
@@ -91,16 +92,16 @@ compute_artifacts() {
load_artifacts() {
local file=$1/ARTIFACTS line key value
[ -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
key=${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"
printf -v "$key" '%s' "$value"
export "${key?}"
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"
}
@@ -127,15 +128,31 @@ preflight() {
for f in "${REQUIRED_FILES[@]}"; do
[ -s "$ROOT/$f" ] || die "missing or empty: $ROOT/$f (run host/secrets-init.sh as root)"
done
# oauth2-proxy uses the client secret file byte for byte.
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"
check_keycloak_secrets "$release"
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)
[ -z "$bad" ] || die "petrolyst-insights/ holds customization only; found: $bad"
compose "$release" traefik config -q
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
@@ -231,7 +248,7 @@ probe() {
}
smoke_tests() {
local i body code location
local i body code location realm
log "smoke tests"
# First deploy: Let's Encrypt may need a minute to issue the certificate.
for i in $(seq 1 24); do
@@ -241,23 +258,40 @@ smoke_tests() {
sleep 5
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/)
[ "$code" = 302 ] && [[ $location == "https://$ATOMA_HOST/login/keycloak"* ]] ||
die "/login/ should redirect to /login/keycloak, got $code $location"
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* ]] ||
die "/login/keycloak should redirect to Keycloak with client_id=petrolyst and PKCE S256, got $code $location"
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"
fm exec -T trino trino --execute 'SELECT count(*) FROM fuel.information_schema.schemata' >/dev/null ||
die "Trino cannot read the fuel catalog"
fm exec -T superset curl -fsS --max-time 10 http://trino:8080/v1/info >/dev/null ||
die "Superset cannot reach Trino"
# Superset's own connection "Fuel" (fuel_ro, password from /run/secrets) reads fuel-db and
# cannot write to it.
fm exec -T superset /app/.venv/bin/python - <<'PY' || die "Superset cannot read fuel-db as fuel_ro, or fuel_ro can write"
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"
}
+9 -24
View File
@@ -2,27 +2,17 @@
# One-time host preparation for the petrolyst server (Ubuntu 24.04), run as root.
# Idempotent: re-running converges to the same state.
#
# bootstrap.sh --ci-key <public-key-file> --runner-ip <ip|any>
# bootstrap.sh
#
# --ci-key public half of the dedicated CI deploy key (DEPLOY_SSH_KEY in Gitea)
# --runner-ip egress IP of the Gitea act_runner; the deploy key works only from
# there ("any" drops that restriction, e.g. before the runner exists)
# Deploys are manual: a person runs them over root SSH, and root hands over to
# the deploy user (README, "Manual deploy"). There is no CI runner, so deploy
# has no SSH key of its own.
set -euo pipefail
die() { echo "ERROR: $*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root"
ci_key='' runner_ip=''
while [ $# -gt 0 ]; do
case $1 in
--ci-key) ci_key=$2; shift 2 ;;
--runner-ip) runner_ip=$2; shift 2 ;;
*) die "unknown argument $1" ;;
esac
done
[ -s "$ci_key" ] || die "--ci-key <public-key-file> is required"
[ -n "$runner_ip" ] || die "--runner-ip <ip|any> is required"
grep -qE '^ssh-(ed25519|rsa) ' "$ci_key" || die "$ci_key does not look like an SSH public key"
[ $# -eq 0 ] || die "bootstrap.sh takes no arguments (deploys are manual; there is no CI key)"
ROOT=/opt/petrolyst
@@ -50,7 +40,7 @@ if [ "$(cat /etc/docker/daemon.json 2>/dev/null)" != "$daemon_json" ]; then
fi
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
[ -e /swapfile ] || fallocate -l 4G /swapfile
chmod 600 /swapfile
@@ -69,15 +59,10 @@ ufw default allow outgoing >/dev/null
for port in 22 80 443; do ufw allow "$port/tcp" >/dev/null; done
ufw --force enable >/dev/null
echo "== deploy user (CI)"
echo "== deploy user (runs deploy.sh; reached only through sudo from root)"
id deploy >/dev/null 2>&1 || useradd --create-home --shell /bin/bash deploy
usermod -aG docker deploy # docker access makes deploy root-equivalent; the key restrictions below matter
install -d -o deploy -g deploy -m 0700 /home/deploy/.ssh
options=restrict
[ "$runner_ip" = any ] || options="restrict,from=\"$runner_ip\""
printf '%s %s\n' "$options" "$(cat "$ci_key")" >/home/deploy/.ssh/authorized_keys
chown deploy:deploy /home/deploy/.ssh/authorized_keys
chmod 0600 /home/deploy/.ssh/authorized_keys
usermod -aG docker deploy # docker access makes deploy root-equivalent, so it gets no SSH key
rm -f /home/deploy/.ssh/authorized_keys # the former CI key, if an earlier bootstrap installed it
echo "== SSH: keys only"
[ -s /root/.ssh/authorized_keys ] || die "root has no authorized_keys; refusing to turn off password login"
+9 -20
View File
@@ -1,8 +1,9 @@
#!/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 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
#
# 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
SECRETS=$ROOT/secrets
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
# byte for byte), replaced atomically.
@@ -50,8 +51,8 @@ case ${1:-all} in
fi
value=$(printf '%s' "$value" | tr -d '[:space:]')
[ -n "$value" ] || die "empty secret"
write_secret "$SECRETS/shared/keycloak_petrolyst_client_secret" "$value"
echo "stored $SECRETS/shared/keycloak_petrolyst_client_secret"
write_secret "$SECRETS/shared/keycloak_petrolyst_realm_client_secret" "$value"
echo "stored $SECRETS/shared/keycloak_petrolyst_realm_client_secret"
exit 0
;;
all) ;;
@@ -62,26 +63,14 @@ esac
generate "$SECRETS/fuel-monitoring/superset_secret_key" 32
generate "$SECRETS/fuel-monitoring/superset_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.
generate "$SECRETS/traefik/oauth2_proxy_cookie_secret" 16
# Trino reads the catalog password from its environment (${ENV:...}); keep the
# 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
if [ ! -s "$SECRETS/shared/keycloak_petrolyst_realm_client_secret" ]; then
echo
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"
exit 1
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/` |
| `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/…` |
| `patches/` | runtime patches, `fix_*.py` with `--verify` (see its README); empty at start | `/app/patches/petrolyst/`, applied during the build |
| `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/` |
| `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 |
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_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).
## 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
`KEYCLOAK_REALM_URL`.
- 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-editor` | Alpha |
| `superset-viewer` | Gamma |
| `atoma-reader` | none (Public): ATOMA's users use the Petrolyst app, not Superset (PD-13) |
| none | Public (no permissions) |
- `DASHBOARD_RBAC` is on: a viewer sees a dashboard once an admin adds the Gamma role (or a
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
Source: `petrolyst-ui/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
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-web (then petrolyst-ui) commit
`2b2e694`. Copy again when the brand changes.
- Light theme: action teal `#00695C` for primary and links, navy `#0B1D33` text, `#F8FAFC`
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.
- 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
@@ -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/
Exported Superset dashboards 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
dashboards exist.
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.
- `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):
docker exec fuel-monitoring-superset-1 superset export-dashboards -f /tmp/dashboards.zip
docker cp fuel-monitoring-superset-1:/tmp/dashboards.zip .
docker exec petrolyst-superset-1 superset export-dashboards -f /tmp/dashboards.zip
docker cp petrolyst-superset-1:/tmp/dashboards.zip .
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
+595
View File
@@ -0,0 +1,595 @@
# ATOMA dashboards: Overview and Site, for the Petrolyst app
**Status (30 Oct 2026, PD-21: every customer sees these two, limited by row; slugs `overview` and `site`, formerly
`atoma-overview` and `atoma-site`, which the rest of this file still uses). Two dashboards (§10), built and
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).
**Datasets.** The `public.v_*` views of
`stacks/fuel-monitoring/fuel-db/migrations/0003_bi_views.sql`, read from fuel-db directly (database
`fuel`, schema `public`) as the read-only role `fuel_ro`.
**Design and rationale** live in the fuel-monitoring corpus, in
`.context/plans/2026-10-24-fuel-db-baseline-dashboard.md`:
- its §6 is this specification;
- 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.
Nothing in the dashboards is specific to a site.
## 1. Capabilities of the installed Superset 6.1.0
Source: read-only inspection of `config.py`, `superset_config.py` and the frontend bundle on
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 |
|---|---|---|
| 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 |
| 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. 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` (confirmed in P2) | colour by `baseline_status_label` |
| 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 |
| 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 |
| 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 |
| 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.
## 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 (slug atoma)
filters, in Superset's left column: Site (exactly one, searchable) · Viewing period
— both out of scope on Map; readers have no column there (§6)
[Map] the fleet-map view (iframe, /views/fleet-map/), the page's full width
towers fitted to the placed sites ──hover or click──► popup
"Find a site" (code or name) ──► the map flies to it ──► its popup
└─"Site details"──► /superset/dashboard/atoma/?native_filters=(site preset)#TAB-site
sites without an accepted location: counted in the map's key ("Choose them in Insights",
the view's tab_label parameter); chosen in the Site filter
[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)
```
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.
```
[Fleet tab: MARKDOWN-fleet-map, w12 h100]
<iframe src="/views/fleet-map/?detail=/superset/dashboard/atoma/&filter=NATIVE_FILTER-site&tab=TAB-site">
```
| Part | Content | Missing or insufficient |
|---|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| 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 | — |
**Baseline status and tower colours.** Colours are neutral categorical hues, with no red or green
and no reference to L/h:
| Status | Definition (`fm.v_site_baseline_status`) | Colour |
|---|---|---|
| Approved baseline | an approved `baseline_version` with a decision reference. The **only** state that says "approved" | teal #009688 |
| Eligible – not yet approved | no approval; the latest proposed KPI version has `meets_policy` (BP-1: ≥ 2 eligible G1 inputs, ≥ 30 matched days, no conditions, one population and metric) | blue #3987e5 |
| 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 |
**Nearby sites.** Towers that would overlap cluster into a teal circle with their count (radius
36 px, up to zoom 13); a click on it zooms in until they separate. The pilot's sites, a few
kilometres apart, separate at the zoom the map opens with. The towers are one symbol layer, so
thousands of sites stay fast.
**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**
- **Site** (`NATIVE_FILTER-site`):
- 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;
- every detail dataset carries `site_label` from `v_site`, so this one filter drives all of
them (confirmed in P2: every chart request carries the selected site).
- **Viewing period** (`NATIVE_FILTER-period`):
- Time range, default no filter;
- scope: the time-series charts on Fuel & Deliveries and Runtime & Electricity only;
- Summary and Baseline & Evidence are outside its scope, so a viewing range never changes a
baseline's fixed window.
**Exactly one site, and never network totals:**
1. A required, single-value filter. *P2: "required" alone does not stop the charts: with no
value they queried every site.*
2. Therefore "Select first filter value by default", with `requiredFirst`, as the filter editor
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`,
never aggregated by Superset. *(29 Oct, PD-12: the app's Overview, §10, adds up recorded
amounts across sites; per-site rates are still never averaged.)*
4. The Handlebars "Select one site" guard is gone with Handlebars (§1); with (2) the filter is
never empty.
```
[D0 Site: one-row table, code · name · province · pilot status · baseline status · location] (28 Oct: inside Site details, no link)
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]
Fuel & Deliveries [F1 Deliveries w6][F2 Recorded stock w6]
[F5 Deliveries table w12] (P2)
[F3 Fuel per calendar day by interval w12]
[F4 Interval table w12]
Runtime & Electricity[R1 Runtime per day by meter interval w6][R2 Hour-meter evidence 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]
[B3 All intervals: eligibility and exclusions w12]
[B4 Interval L/h against baseline w12]
[B5 Assumptions w6][B6 Missing data and quality checks w6]
[B7 Evidence references w6][B8 Workbook calculations, assessed w6]
[B9 Stock against engine-measured fuel w6][B10 Equipment specifications w6]
[B11 Modelled L/h under an assumed runtime w12]
```
| # | Question it answers | Type | Dataset | Dims / measures (units) | Grain | When data are missing or insufficient |
|---|---|---|---|---|---|---|
| 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? | 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" |
| 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)" | 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`, 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 | 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 |
| 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" |
| 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 |
| 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 |
| B3 | Which intervals are usable, and why not? | Table | v_interval | eligibility, exclusion reasons, grade, conditions, in current baseline | interval | v1: every interval excluded, "no timer reading at both ends" (and "a generator has neither a counter nor an idle assumption") |
| B4 | What L/h did each interval give, against the approved baseline? | Mixed: bars + line | v_interval | x `interval_label`; A: MAX(`l_per_h`) where eligibility ≠ excluded; B: MAX(`current_baseline_l_per_h`) (approved value only, and only beside intervals of the same metric and population, `baseline_comparable`) | interval | v1: no bars; description points to S1 |
| B5 | Which assumptions apply? | Table | v_assumption | code, topic, value and range, basis, status, rationale | assumption | "None recorded" |
| 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 | — |
| 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 | 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 |
| 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
the runtime assumption's dates, and shows that window and its days. It never reaches S1 or the
fleet map.
## 5. Datasets and filter mapping
Physical datasets, one per `public.v_*` view in fuel-db's `public` schema, plus one virtual dataset
*(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 |
|---|---|---|---|
| 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_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_stock_reading | observation | F2 | Site, Viewing period |
| v_interval | stock interval | F3, F4, B3, B4 | Site; Viewing period on F3 and F4 only |
| v_runtime | meter interval | R1 | Site, Viewing period |
| v_hour_meter_evidence | reading | R2 | Site, Viewing period |
| v_electricity_cost | bill month | R4 | Site, Viewing period |
| v_baseline_version, v_baseline_input | version (× interval) | B1, B2 | Site |
| v_assumption, v_data_quality, v_evidence, v_imported_calculation, v_reconciliation, v_spec_reference, v_modelled_lph | per view | B5–B11 | Site |
**Metrics.** Saved metrics enforce pooling, with the same eligibility filter on numerator and
denominator, e.g. `pooled_l_per_h = SUM(CASE WHEN eligibility <> 'excluded' THEN fuel_used_l END)
/ NULLIF(SUM(CASE WHEN eligibility <> 'excluded' THEN runtime_h END), 0)`. A chart that uses it
is grouped or filtered by `metric` and `population_key` (both in `v_interval`), so it never pools
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.
**Site details link** *(28 Oct; history since PD-13)*. The fleet-map view built it
(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:
```
/superset/dashboard/atoma/?native_filters=<url-encoded:>
(NATIVE_FILTER-site:(id:NATIVE_FILTER-site,extraFormData:(filters:!((col:site_label,op:IN,val:!(q)))),
filterState:(value:!(q)),ownState:()))
#TAB-site
-- q = the site label in rison quotes: 'label', with ! written !! and ' written !'
```
## 6. Build as code, scale, access
**Build**
- `build_bundle.py --database-uuid <uuid>` writes one import bundle containing the dashboard,
every dataset and every chart:
- stable UUIDs (uuid5), so a re-import updates instead of duplicating; the zip is
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;
- every view column, from `columns.tsv` (a snapshot of PostgreSQL's catalog);
- 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**
- Import its `site.yaml` and its records. Nothing in the dashboard or the fleet-map view names a
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**
- `v_site` has one row per site; the fleet map draws its towers as one symbol layer and
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 changes** (in `superset_config.py`; image rebuild in P3):
- `DECKGL_BASE_MAP = [["tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap"]]`
(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).
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'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
*(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.
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
location. The counts are unchanged; site 4 appears on the fleet map and in the Site filter;
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
name; every chart's query (View query, or chart-data API) carries `site_label = …<site B>…`; no
chart keeps site A's rows.
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.
4. **Unavailable L/h.** For each pilot site:
- 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;
- B3 shows every interval excluded, with its reason;
- 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 counted in the
map's key, and can be chosen in the Site filter *(28 Oct)*.
6. **Filter propagation.**
- *(28 Oct)* Both filters are out of scope on the Fleet tab; the fleet map shows every placed
site, sites that are not active pilot sites faded.
- In Site details, the Viewing period changes F1–F5, R1, R1T, R2, R4 and R4T, but not S1 or
B1–B4.
7. **Detail links** *(28 Oct)*. For each placed site, "Site details" in its popup opens the Site
details tab with exactly that site selected and Summary open; D0 matches, and every chart's
request carries that site.
8. **Colours.**
- Fixtures in each status show the four colours.
- "Eligible" never renders as "Approved" unless the fixture has an approved version with a
decision reference.
- Marker size is identical across sites.
- *(28 Oct)* An approved site whose inputs changed shows its validity note beside the status
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.
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:
- 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.
- /logout/ also ends the Keycloak session, so the next visit asks for the
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
@@ -70,6 +72,14 @@ class PetrolystSecurityManager(SupersetSecurityManager):
# FAB's OAuth view, and that page wins the route.
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(
self, provider: str, response: Optional[dict[str, Any]] = None
) -> 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",
).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") ----------------------------
# Users and roles are managed in Keycloak; there is no local admin account.
# Break-glass access: `superset fab ...` inside the container on the server.
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("/")
AUTH_TYPE = AUTH_OAUTH
@@ -79,15 +94,26 @@ AUTH_ROLES_MAPPING = {
"superset-admin": ["Admin"],
"superset-editor": ["Alpha"],
"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_USER_REGISTRATION = True
# Public has no permissions: a Keycloak user without a Superset role signs in
# and sees nothing.
# Public has no permissions. A Keycloak user without a mapped role never gets
# this far: PetrolystSecurityManager refuses the sign-in and makes no account.
AUTH_USER_REGISTRATION_ROLE = "Public"
# 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 -----------------------------------------------------------
ENABLE_PROXY_FIX = True
@@ -128,12 +154,12 @@ EXPLORE_FORM_DATA_CACHE_CONFIG = {
RATELIMIT_STORAGE_URI = f"{REDIS_URL}/1"
# --- 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/.
BRANDING = "/static/assets/branding"
APP_NAME = "Petrolyst"
APP_ICON = f"{BRANDING}/petrolyst_logo_standard_fullcolor_no_tagline.svg"
APP_ICON = f"{BRANDING}/petrolyst_wordmark_fullcolor.svg"
FAVICONS = [
{"href": f"{BRANDING}/favicon.svg", "type": "image/svg+xml"},
{"href": f"{BRANDING}/favicon.ico", "type": "image/x-icon"},
@@ -147,10 +173,13 @@ THEME_DEFAULT["token"].update(
"brandLogoAlt": "Petrolyst",
"brandLogoUrl": APP_ICON,
"brandLogoHref": "/",
# The standard logo is stacked (mark over wordmark); 40px keeps the Y
# legible and, with the margin, the 60px navbar height.
"brandLogoHeight": "40px",
"brandLogoMargin": "10px 0",
# The wordmark alone (owner, 29 Oct): in the stacked logo, at 40px, its
# letters were about 5px high. Its own Y carries the brand accent. 24px
# (letters about 20px) and the margin fill the 60px navbar height. On the
# 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
# raw teal off small text and controls.
"colorPrimary": "#00695C",
@@ -160,8 +189,8 @@ THEME_DEFAULT["token"].update(
"colorError": "#B3261E",
"colorInfo": "#2563EB",
"colorTextBase": "#0B1D33",
"colorBgLayout": "#F8FAFC",
"colorBorder": "#E2E8F0",
"colorBgLayout": "#F7F9FA",
"colorBorder": "#DDE5E9",
"borderRadius": 8,
"fontFamily": "Inter, system-ui, sans-serif",
}
@@ -171,7 +200,7 @@ THEME_DARK = deepcopy(THEME_DEFAULT)
THEME_DARK["algorithm"] = "dark"
THEME_DARK["token"].update(
{
"brandLogoUrl": f"{BRANDING}/petrolyst_logo_standard_white_no_tagline.svg",
"brandLogoUrl": f"{BRANDING}/petrolyst_wordmark_white_y.svg",
"colorPrimary": "#35C5B6",
"colorLink": "#35C5B6",
"colorSuccess": "#35C5B6",
@@ -179,10 +208,10 @@ THEME_DARK["token"].update(
"colorError": "#FF6B6B",
"colorInfo": "#60A5FA",
"colorTextBase": "#F8FAFC",
"colorBgBase": "#071425",
"colorBgLayout": "#071425",
"colorBgContainer": "#12263D",
"colorBorder": "#526D82",
"colorBgBase": "#1F2A33",
"colorBgLayout": "#1F2A33",
"colorBgContainer": "#2A3945",
"colorBorder": "#76828E",
"colorEditorSelection": superset_defaults.THEME_DARK["token"][
"colorEditorSelection"
],
@@ -190,7 +219,7 @@ THEME_DARK["token"].update(
)
# 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
# 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
+16 -29
View File
@@ -1,12 +1,11 @@
# Fuel-monitoring analytics: Superset on atoma.petrolyst.com, reading the
# operational store (fuel-db) through Trino. Run only through deploy/deploy.sh
# or deploy/rollback.sh: they export SUPERSET_IMAGE_TAG and TRINO_CONFIG_HASH
# from the release's ARTIFACTS file, and the :? guards below stop Compose from
# running with either unset.
name: fuel-monitoring
# operational store (fuel-db) directly as the read-only role fuel_ro. Run only through deploy/deploy.sh or
# deploy/rollback.sh: they export SUPERSET_IMAGE_TAG from the release's
# ARTIFACTS file, and the :? guard below stops Compose from running without it.
name: petrolyst
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
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_db_password
- keycloak_petrolyst_client_secret
# fuel_ro's password, read at connect time (superset_config.py)
- fuel_db_ro_password
services:
superset:
@@ -100,25 +101,6 @@ services:
timeout: 5s
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:
image: timescale/timescaledb:2.31.0-pg17
environment:
@@ -128,7 +110,7 @@ services:
TS_TUNE_NUM_CPUS: "2"
secrets:
- fuel_db_password
- fuel_db_trino_password
- fuel_db_ro_password
volumes:
- fuel_db_data:/var/lib/postgresql/data
# 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.
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:
superset_db_data:
name: fuel-monitoring_superset_db_data
fuel_db_data:
name: fuel-monitoring_fuel_db_data
secrets:
superset_secret_key:
@@ -168,7 +155,7 @@ secrets:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/superset_db_password
fuel_db_password:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_password
fuel_db_trino_password:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_trino_password
fuel_db_ro_password:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_ro_password
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
# "Data loaders"): the default privileges below cover only what
# 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
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" \
-v trino_password="$trino_password" <<'SQL'
-v ro_password="$ro_password" <<'SQL'
CREATE ROLE fuel_owner NOLOGIN;
ALTER DATABASE fuel OWNER TO fuel_owner;
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
CREATE ROLE trino_ro LOGIN PASSWORD :'trino_password';
GRANT CONNECT ON DATABASE fuel TO trino_ro;
GRANT USAGE ON SCHEMA public TO trino_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO trino_ro;
CREATE ROLE fuel_ro LOGIN PASSWORD :'ro_password';
ALTER ROLE fuel_ro SET default_transaction_read_only = on;
GRANT CONNECT ON DATABASE fuel TO fuel_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
GRANT SELECT ON TABLES TO trino_ro;
GRANT SELECT ON TABLES TO fuel_ro;
SQL
@@ -0,0 +1,976 @@
-- 0001_fm_core.sql: fuel-db schema `fm` (tables, constraints, trigger-backed rules).
-- 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
-- .context/plans/2026-10-24-fuel-db-baseline-dashboard.md (sections cited as "design §n").
-- 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;
CREATE SCHEMA fm AUTHORIZATION fuel_owner;
CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA fm; -- trusted extension; for EXCLUDE on text
-- Default GiST operator classes are found by type, independent of search_path; fm is put first
-- 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;
-- 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 ------------------------------------------------------------------------------
CREATE TABLE fm.source_document (
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'
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',
'operator_record','survey_workbook','photo','datasheet','mail','our_analysis')),
issuer text NOT NULL, -- organisation or role, never a person
title text NOT NULL,
document_date date,
corpus_path text, -- NULL when held outside the repository
transcription_path text, -- the corpus note that transcribes it
contains_personal_data boolean NOT NULL,
supersedes_sha256 text UNIQUE,
null_reasons jsonb NOT NULL DEFAULT '{}',
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 (
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_path text NOT NULL,
source_sha256 text NOT NULL CHECK (source_sha256 ~ '^[0-9a-f]{64}$'),
corpus_commit text,
importer_version text NOT NULL,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
status text NOT NULL DEFAULT 'running' CHECK (status IN ('running','loaded','failed')),
report jsonb NOT NULL DEFAULT '{}', -- counts, unmapped paths, conflicts
UNIQUE (operator_uid, import_batch_id)
);
CREATE TABLE fm.import_record (
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'
source_locator text NOT NULL, -- YAML path or sheet!range as read
payload jsonb NOT NULL, -- original values, strings as written, 'UNKNOWN' kept
payload_sha256 text NOT NULL,
first_batch_id bigint NOT NULL,
last_seen_batch_id bigint NOT NULL,
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: asset kinds and assets, places and holders (PD-18) --------------------------------
CREATE TABLE fm.asset_kind ( -- what an asset does with fuel (PD-18); global, not per customer
kind text PRIMARY KEY,
fuel_role text CHECK (fuel_role IN ('container','consumer','transfer','sensor'))
-- NULL: carries other assets and holds no fuel itself
);
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,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL, -- 'site-yaml:SITE-0001:coordinates'
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), -- operator, place, lat, lon, precision, basis
place_uid text NOT NULL,
latitude numeric(9,6) NOT NULL CHECK (latitude BETWEEN -90 AND 90),
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
basis text NOT NULL CHECK (basis IN ('operator_plate','operator_workbook','field_gps','geocoded')),
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')),
accepted_on date, accepted_by_role text, acceptance_ref text, -- copied from the matching fm.review row
superseded_by bigint,
import_record_id bigint 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),
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 place_location_live_key ON fm.place_location (operator_uid, source_key)
WHERE acceptance IN ('pending','accepted','rejected');
CREATE UNIQUE INDEX place_location_one_accepted ON fm.place_location (place_uid) WHERE acceptance = 'accepted';
CREATE TABLE fm.holder_alias (
holder_alias_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
holder_uid 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
is_primary boolean NOT NULL DEFAULT false,
null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK (valid_from IS NOT NULL OR null_reasons ? 'valid_from'),
EXCLUDE USING gist (operator_uid WITH =, issuer WITH =, label WITH =,
daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&),
EXCLUDE USING gist (holder_uid WITH =, issuer WITH =,
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.participation (
holder_uid text NOT NULL,
operator_uid text NOT NULL REFERENCES fm.operator,
programme text NOT NULL,
status text NOT NULL CHECK (status IN ('active','reference','candidate','withdrawn')),
valid_from date NOT NULL, valid_to date,
decision_ref text, note text,
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_placement ( -- in or on a holder, or at a place: exactly one (PD-18)
placement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
asset_uid text NOT NULL,
holder_uid text,
place_uid text,
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)
earliest_evidence_on date,
installed_on date,
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'),
EXCLUDE USING gist (asset_uid WITH =,
daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&),
EXCLUDE USING gist (holder_uid WITH =, slot_label 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 (
meter_id text PRIMARY KEY, -- ours, stable: 'MTR-SITE-0001-GENSET1'
operator_uid text NOT NULL REFERENCES fm.operator,
holder_uid text NOT NULL,
source_label text NOT NULL, -- as written: 'Genset 1', 'R.H'
asset_uid text,
mapping_status text NOT NULL CHECK (mapping_status IN ('confirmed','proposed','unmapped')),
mapping_basis text,
UNIQUE (holder_uid, source_label),
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 (
epoch_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
meter_id text NOT NULL,
epoch_no smallint NOT NULL,
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')),
continuity text NOT NULL CHECK (continuity IN ('confirmed','assumed','uncertain')),
rollover_at numeric(9,1),
source_sha256 text, note text,
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) ---------------------------------------------------------------------
CREATE TABLE fm.visit (
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'
holder_uid text NOT NULL,
visit_on date NOT NULL, -- holder-local calendar date
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
kind text NOT NULL CHECK (kind IN ('delivery','stock_check','maintenance','survey')),
-- descriptive only: views use fm.v_visit_live and the live claims
UNIQUE (holder_uid, visit_on, seq_in_day),
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
-- only, and drops out of every analytical view (fm.v_visit_live).
-- Claim columns, repeated in each claim table:
-- source_key, assertion_sha256, source_sha256, record_ref, import_record_id, claim_status,
-- superseded_by, review_note, null_reasons
CREATE TABLE fm.fuel_movement (
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'
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';
-- claims sharing it are alternative reports of ONE movement
visit_id bigint NOT NULL,
holder_uid text NOT NULL,
kind text NOT NULL CHECK (kind IN ('delivery','external_in','internal_transfer',
'withdrawal','drain','spill','loss_reported')),
from_tank_uid text, to_tank_uid text,
source_tank_label text,
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')),
density_kg_per_l numeric(5,3),
unit_price numeric(8,2), price_currency text,
source_sha256 text NOT NULL,
record_ref text NOT NULL, -- 'form <no>' | '<sheet>!J20' | 'logbook p2 r1'
import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint,
review_note text,
null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK ((litres IS NULL) = (litres_state IN ('not_recorded','illegible'))),
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)),
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 (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE INDEX ON fm.fuel_movement (visit_id, event_key);
CREATE TABLE fm.stock_observation (
stock_obs_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL,
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
visit_id bigint NOT NULL,
holder_uid text NOT NULL,
scope text NOT NULL CHECK (scope IN ('site_total','tank')),
tank_uid text, -- only when the source identifies the tank
source_tank_label text, -- 'T1' as written, even when unmapped
phase text NOT NULL CHECK (phase IN ('before_delivery','after_delivery','standalone')),
level_cm numeric(6,1),
litres numeric(8,1),
litres_basis text NOT NULL CHECK (litres_basis IN ('gauge_converted_by_source','stated_without_level',
'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_basis text,
source_sha256 text NOT NULL,
record_ref text NOT NULL,
import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint,
review_note text,
null_reasons jsonb NOT NULL DEFAULT '{}',
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 (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 (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE INDEX ON fm.stock_observation (visit_id, phase);
CREATE TABLE fm.hour_meter_reading (
reading_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL,
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
visit_id bigint NOT NULL,
meter_id text NOT NULL,
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')),
date_basis text NOT NULL CHECK (date_basis IN ('stated','inferred_previous_visit','capture_date_unknown')),
hours numeric(9,1),
resolution_h numeric(4,1) NOT NULL DEFAULT 1.0,
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
transcribed_by text NOT NULL CHECK (transcribed_by IN ('source_operator','our_reading')),
source_sha256 text NOT NULL,
record_ref text NOT NULL,
import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint,
review_note text,
null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK ((hours IS NULL) = (reading_state IN ('ambiguous','illegible','crossed_out','not_recorded'))),
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 (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE INDEX ON fm.hour_meter_reading (visit_id, meter_id);
CREATE TABLE fm.runtime_interval ( -- future: controller / ATS logs, telemetry
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
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
holder_uid text NOT NULL,
asset_uid text,
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),
source_kind text NOT NULL CHECK (source_kind IN ('controller_event_log','ats_log','gateway_telemetry','manual_log')),
source_sha256 text,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint,
CHECK ((scope = 'generator') = (asset_uid IS NOT NULL)),
CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL),
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')),
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 (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE TABLE fm.engine_fuel_total ( -- future: flow-meter net fuel per period
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
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
asset_uid text NOT NULL,
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
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_sha256 text,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint,
CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL),
EXCLUDE USING gist (asset_uid WITH =, tstzrange(started_at, ended_at) WITH &&)
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 (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE TABLE fm.electricity_bill (
bill_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
operator_uid text NOT NULL REFERENCES fm.operator,
source_key text NOT NULL,
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
holder_uid text NOT NULL,
bill_month date NOT NULL CHECK (extract(day FROM bill_month) = 1), -- month as the source assigns it
period_start date, period_end date,
amount numeric(12,2),
currency text, currency_basis text CHECK (currency_basis IN ('stated','inferred')),
kwh numeric(10,1),
source_sha256 text NOT NULL,
record_ref text NOT NULL,
import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint,
null_reasons jsonb NOT NULL DEFAULT '{}',
CHECK (kwh IS NOT NULL OR null_reasons ? 'kwh'),
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 (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed');
-- Specifications, assumptions, imported calculations -------------------------------------
CREATE TABLE fm.equipment_spec ( -- versioned like a claim: corrections supersede
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'
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
asset_uid text NOT NULL,
attribute text NOT NULL, -- rated_prime_kw, capacity_l, fuel_rate_at_load, …
load_pct numeric(5,1),
value_num numeric(12,3), value_text text, unit text,
basis text NOT NULL CHECK (basis IN ('nameplate','manufacturer_datasheet','derived_from_datasheet',
'operator_stated','our_inference','geometry')),
applicability text NOT NULL DEFAULT 'confirmed' CHECK (applicability IN ('confirmed','conditional','unconfirmed')),
applicability_note text,
source_sha256 text,
import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint,
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)),
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 (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed');
CREATE TABLE fm.assumption ( -- one row per version; a change is a new version
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'
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
holder_uid text NOT NULL,
asset_uid text,
topic text NOT NULL CHECK (topic IN ('runtime_hours_per_day','generator_idle','meter_coverage',
'tank_linkage','no_unrecorded_movements','density','other')),
-- meter_coverage: its members name the exact counters,
-- the sets they cover together and the idle sets
value_num numeric(10,3), low_num numeric(10,3), high_num numeric(10,3), unit text,
applies_from date, applies_to date,
basis text NOT NULL CHECK (basis IN ('operator_stated','our_assumption','evidence_based')),
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')),
accepted_on date, accepted_by_role text, decision_ref text, -- copied from the matching fm.review row
supersedes_id bigint UNIQUE,
rationale text NOT NULL, contradicted_by text,
source_sha256 text,
CHECK (status <> 'accepted' OR (accepted_on IS NOT NULL AND accepted_by_role IS NOT NULL)),
CONSTRAINT assumption_no_runtime_from_fuel -- rule 5
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 <> 'meter_coverage' OR asset_uid IS NULL), -- its sets are in assumption_member
EXCLUDE USING gist (holder_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&)
WHERE (topic = 'meter_coverage' AND status IN ('proposed','accepted')),
EXCLUDE USING gist (asset_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&)
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
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')),
meter_id text,
asset_uid text,
CHECK ((member_role = 'counter') = (meter_id IS NOT 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_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
CREATE TABLE fm.imported_calculation ( -- versioned like a claim: a revised formula supersedes
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>'
assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'),
source_sha256 text NOT NULL,
cell_ref text NOT NULL, -- '<sheet>!K2' | '<survey item>'
holder_uid text,
source_label text NOT NULL, -- as written in the source
formula text, cached_value numeric(14,6), unit_as_labelled text,
our_reading text NOT NULL, -- what it actually computes
baseline_use text NOT NULL CHECK (baseline_use IN ('excluded','context_only')),
exclusion_reason text,
import_record_id bigint NOT NULL,
claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')),
superseded_by bigint,
CHECK (claim_status <> 'superseded' OR superseded_by 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 (operator_uid, source_key)
WHERE claim_status IN ('asserted','reviewed','disputed');
-- Reviews ---------------------------------------------------------------------------------
CREATE TABLE fm.review ( -- one applied entry of the corpus review manifest
review_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
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_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
checked_against_sha256 text, -- the file version compared with
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,
import_batch_id bigint NOT NULL,
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 (operator_uid, subject_key, assertion_sha256);
-- Baseline --------------------------------------------------------------------------------
CREATE TABLE fm.baseline_version (
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'
holder_uid text NOT NULL,
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')),
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
-- to a set plus 'covers:<asset uid>' for the sets they
-- 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),
policy_version text NOT NULL, -- 'BP-1'
meets_policy boolean NOT NULL DEFAULT false,-- set by `baseline propose` from the inputs (BP-1 test)
policy_check text, -- which BP-1 conditions failed, if any
value numeric(8,3), range_low numeric(8,3), range_high numeric(8,3),
fuel_l numeric(10,1), runtime_h numeric(10,1), matched_days integer,
conditions text, -- assumption codes the value depends on
evidence_grade text CHECK (evidence_grade IN ('G1','G2')),
operator_tier text CHECK (operator_tier IN ('A','B','C')), -- descriptive only (Baseline Verification Plan)
season_note text,
status text NOT NULL CHECK (status IN ('draft','proposed','approved','superseded','rejected')),
computed_at timestamptz NOT NULL, computed_by_role text NOT NULL,
approved_on date, approved_by_role text, decision_ref text,
supersedes_id bigint,
UNIQUE (operator_uid, baseline_id, holder_uid),
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 (holder_uid, metric) WHERE status = 'approved';
CREATE TABLE fm.baseline_input ( -- an interval is identified by its two endpoint visits
baseline_id bigint NOT NULL,
operator_uid text NOT NULL REFERENCES fm.operator,
holder_uid text NOT NULL,
open_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),
included boolean NOT NULL, exclusion_reason text,
fuel_l numeric(10,1), runtime_h numeric(10,1), days integer, population_key text, -- snapshot at computation
eligibility_at_computation text NOT NULL, evidence_grade_at_computation text,
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
PRIMARY KEY (baseline_id, open_visit_id, close_visit_id),
FOREIGN KEY (operator_uid, baseline_id, holder_uid) REFERENCES fm.baseline_version (operator_uid, baseline_id, holder_uid),
FOREIGN KEY (operator_uid, open_visit_id, holder_uid) REFERENCES fm.visit (operator_uid, visit_id, holder_uid), -- both endpoints belong
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 (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)
);
-- Trigger-backed rules ------------------------------------------------------------------------
-- Numbered as in the design (fuel-monitoring .context/plans/2026-10-24-fuel-db-baseline-dashboard.md).
-- Rule 5 is the CHECK assumption_no_runtime_from_fuel above. Rules 6 and 1 together cover
-- baseline_input; `baseline propose` stays the only writer by convention.
-- 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 $$
SELECT CASE tbl
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']
ELSE ARRAY['claim_status','superseded_by','review_note']
END $$;
-- 11. Status only from a matching review, and no in-place correction.
CREATE FUNCTION fm.tg_status_from_review() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
j jsonb := to_jsonb(NEW);
v_kind text; v_subject text; v_fp text; v_verdict text; r fm.review;
BEGIN
IF TG_OP = 'UPDATE' AND (to_jsonb(OLD) - fm.mutable_columns(TG_TABLE_NAME))
IS DISTINCT FROM (j - fm.mutable_columns(TG_TABLE_NAME)) THEN
RAISE EXCEPTION '%: only the status columns change in place; a corrected value is a new row that supersedes this one',
TG_TABLE_NAME;
END IF;
IF TG_TABLE_NAME = 'place_location' THEN
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;
ELSIF TG_TABLE_NAME = 'assumption' THEN
v_kind := 'assumption'; v_subject := j->>'code'; v_fp := j->>'fingerprint'; v_verdict := j->>'status';
IF v_verdict NOT IN ('accepted','rejected') THEN RETURN NEW; END IF;
ELSE
v_kind := 'claim'; v_subject := j->>'source_key'; v_fp := j->>'assertion_sha256'; v_verdict := j->>'claim_status';
IF v_verdict NOT IN ('reviewed','disputed') THEN RETURN NEW; END IF;
END IF;
SELECT * INTO r FROM fm.review x
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;
IF NOT FOUND THEN
RAISE EXCEPTION '%: status % for % needs a review of this exact assertion (%)',
TG_TABLE_NAME, v_verdict, v_subject, left(v_fp, 16);
END IF;
IF v_verdict = 'accepted' AND ((j->>'accepted_on')::date IS DISTINCT FROM r.reviewed_on
OR j->>'accepted_by_role' IS DISTINCT FROM r.reviewer_role) THEN
RAISE EXCEPTION '%: acceptance date and role must be copied from review %', TG_TABLE_NAME, r.entry_key;
END IF;
RETURN NEW;
END $$;
DO $$
DECLARE t text;
BEGIN
FOREACH t IN ARRAY ARRAY['fuel_movement','stock_observation','hour_meter_reading','electricity_bill',
'equipment_spec','imported_calculation','runtime_interval','engine_fuel_total',
'place_location','assumption'] LOOP
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);
END LOOP;
END $$;
-- 2, 3, 10. A movement claim: same holder as its visit; an internal transfer's two tanks placed on
-- 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.
CREATE FUNCTION fm.tg_fuel_movement_rules() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v fm.visit;
BEGIN
SELECT * INTO v FROM fm.visit WHERE visit_id = NEW.visit_id;
IF v.holder_uid <> NEW.holder_uid THEN
RAISE EXCEPTION 'fuel_movement %: site % differs from its visit''s site %', NEW.source_key, NEW.holder_uid, v.holder_uid;
END IF;
IF NEW.kind = 'internal_transfer' AND EXISTS (
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 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
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;
IF NEW.claim_status IN ('asserted','reviewed','disputed') AND EXISTS (SELECT 1 FROM fm.fuel_movement m
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.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
RAISE EXCEPTION 'fuel_movement %: claims of event % must share visit, kind and tanks', NEW.source_key, NEW.event_key;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER fuel_movement_rules BEFORE INSERT OR UPDATE ON fm.fuel_movement
FOR EACH ROW EXECUTE FUNCTION fm.tg_fuel_movement_rules();
-- 3. A stock claim belongs to its visit's holder.
CREATE FUNCTION fm.tg_stock_observation_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
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.holder_uid;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER stock_observation_rules BEFORE INSERT OR UPDATE ON fm.stock_observation
FOR EACH ROW EXECUTE FUNCTION fm.tg_stock_observation_rules();
-- 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 $$
BEGIN
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
RAISE EXCEPTION 'hour_meter_reading %: meter % is not a meter of the visit''s site', NEW.source_key, NEW.meter_id;
END IF;
IF NEW.epoch_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM fm.meter_epoch e WHERE e.epoch_id = NEW.epoch_id AND e.meter_id = NEW.meter_id) THEN
RAISE EXCEPTION 'hour_meter_reading %: epoch % does not belong to meter %', NEW.source_key, NEW.epoch_id, NEW.meter_id;
END IF;
RETURN NEW;
END $$;
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();
-- 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 $$
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 (
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;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER source_document_rules BEFORE INSERT OR UPDATE ON fm.source_document
FOR EACH ROW EXECUTE FUNCTION fm.tg_source_document_rules();
-- 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.
CREATE FUNCTION fm.tg_runtime_meter_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
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
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.holder_uid;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER runtime_meter_rules BEFORE INSERT OR UPDATE ON fm.runtime_meter
FOR EACH ROW EXECUTE FUNCTION fm.tg_runtime_meter_rules();
-- 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 $$
BEGIN
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
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, '[)')
&& daterange(NEW.applies_from, NEW.applies_to, '[]')) THEN
RAISE EXCEPTION 'assumption %: % is not a generator placed at % during the assumption''s dates',
NEW.code, NEW.asset_uid, NEW.holder_uid;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER assumption_rules BEFORE INSERT OR UPDATE ON fm.assumption
FOR EACH ROW EXECUTE FUNCTION fm.tg_assumption_rules();
-- 9. Members of a meter_coverage assumption: valid when written, written in the assumption's own
-- transaction, immutable afterwards, and complete at commit.
CREATE FUNCTION fm.tg_assumption_member_rules() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE a fm.assumption;
BEGIN
IF TG_OP <> 'INSERT' THEN
RAISE EXCEPTION 'assumption_member: members are immutable; write a new assumption version';
END IF;
SELECT * INTO a FROM fm.assumption WHERE assumption_id = NEW.assumption_id;
IF a.topic <> 'meter_coverage' THEN
RAISE EXCEPTION 'assumption_member: % is not a meter_coverage assumption', a.code;
END IF;
IF a.created_xact <> pg_current_xact_id() THEN
RAISE EXCEPTION 'assumption_member: members of % are written in the transaction that created it', a.code;
END IF;
IF NEW.member_role = 'counter' THEN
IF NOT EXISTS (SELECT 1 FROM fm.runtime_meter m
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.holder_uid;
END IF;
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.holder_uid = a.holder_uid AND s.kind = 'generator') THEN
RAISE EXCEPTION 'assumption_member: % is not a generator placed at %', NEW.asset_uid, a.holder_uid;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER assumption_member_rules BEFORE INSERT OR UPDATE OR DELETE ON fm.assumption_member
FOR EACH ROW EXECUTE FUNCTION fm.tg_assumption_member_rules();
CREATE FUNCTION fm.tg_coverage_members_complete() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE n_counter integer; n_covered integer; code text;
BEGIN
SELECT x.code,
count(*) FILTER (WHERE m.member_role = 'counter'),
count(*) FILTER (WHERE m.member_role = 'covered_generator')
INTO code, n_counter, n_covered
FROM fm.assumption x LEFT JOIN fm.assumption_member m ON m.assumption_id = x.assumption_id
WHERE x.assumption_id = NEW.assumption_id GROUP BY x.code;
IF n_counter < 1 OR n_counter <> n_covered THEN
RAISE EXCEPTION 'assumption %: a meter_coverage assumption needs as many counters as covered generators, at least one (% vs %)',
code, n_counter, n_covered;
END IF;
RETURN NULL;
END $$;
CREATE CONSTRAINT TRIGGER coverage_members_complete AFTER INSERT ON fm.assumption
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (NEW.topic = 'meter_coverage')
EXECUTE FUNCTION fm.tg_coverage_members_complete();
CREATE CONSTRAINT TRIGGER coverage_members_complete AFTER INSERT ON fm.assumption_member
DEFERRABLE INITIALLY DEFERRED FOR EACH ROW
EXECUTE FUNCTION fm.tg_coverage_members_complete();
-- 1. A version starts as draft or proposed. Approval needs meets_policy and unchanged inputs
-- (fm.v_baseline_validity, created in 0002). An approved version is immutable; the only change
-- is approved -> superseded.
CREATE FUNCTION fm.tg_baseline_version_rules() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
IF NEW.status NOT IN ('draft','proposed') THEN
RAISE EXCEPTION 'baseline_version %: a version starts as draft or proposed; approval is a later update', NEW.code;
END IF;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
IF OLD.approved_on IS NOT NULL THEN
RAISE EXCEPTION 'baseline_version %: an approved version is never deleted', OLD.code;
END IF;
RETURN OLD;
END IF;
IF OLD.approved_on IS NOT NULL
AND NOT (OLD.status = 'approved' AND NEW.status = 'superseded'
AND (to_jsonb(OLD) - 'status') = (to_jsonb(NEW) - 'status')) THEN
RAISE EXCEPTION 'baseline_version %: approved rows are immutable; the only change is status -> superseded', OLD.code;
END IF;
IF NEW.status = 'approved' AND OLD.status <> 'approved' THEN
IF NOT NEW.meets_policy THEN
RAISE EXCEPTION 'baseline_version %: does not meet % (%)', NEW.code, NEW.policy_version, coalesce(NEW.policy_check, 'no check recorded');
END IF;
IF NOT coalesce((SELECT inputs_unchanged FROM fm.v_baseline_validity WHERE baseline_id = NEW.baseline_id), false) THEN
RAISE EXCEPTION 'baseline_version %: its inputs changed since computation; re-propose', NEW.code;
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER baseline_version_rules BEFORE INSERT OR UPDATE OR DELETE ON fm.baseline_version
FOR EACH ROW EXECUTE FUNCTION fm.tg_baseline_version_rules();
-- 1, 6. Inputs: written only while the version is draft or proposed, never moved to another
-- version, never changed once their version has been approved; dates equal the endpoint
-- visits; included rows carry the version's population.
CREATE FUNCTION fm.tg_baseline_input_rules() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v fm.baseline_version; o date; c date;
BEGIN
SELECT * INTO v FROM fm.baseline_version
WHERE baseline_id = CASE WHEN TG_OP = 'INSERT' THEN NEW.baseline_id ELSE OLD.baseline_id END;
IF TG_OP <> 'INSERT' AND v.approved_on IS NOT NULL THEN
RAISE EXCEPTION 'baseline_input: the inputs of approved version % are immutable', v.code;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
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;
END IF;
IF v.status NOT IN ('draft','proposed') THEN
RAISE EXCEPTION 'baseline_input: version % is %; inputs are written only while it is draft or proposed', v.code, v.status;
END IF;
SELECT visit_on INTO o FROM fm.visit WHERE visit_id = NEW.open_visit_id;
SELECT visit_on INTO c FROM fm.visit WHERE visit_id = NEW.close_visit_id;
IF NEW.start_on <> o OR NEW.end_on <> c THEN
RAISE EXCEPTION 'baseline_input: dates % - % differ from the endpoint visits % - %', NEW.start_on, NEW.end_on, o, c;
END IF;
IF NEW.included AND NEW.population_key IS DISTINCT FROM v.population_key THEN
RAISE EXCEPTION 'baseline_input: included population % differs from version %''s %', NEW.population_key, v.code, v.population_key;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER baseline_input_rules BEFORE INSERT OR UPDATE OR DELETE ON fm.baseline_input
FOR EACH ROW EXECUTE FUNCTION fm.tg_baseline_input_rules();
-- 13. Identity and classification never change in place: the derivations and the dependency
-- hashes (0002) rest on them. A wrong visit date is corrected by a new visit, which the
-- superseding claims point to.
CREATE FUNCTION fm.tg_fixed_columns() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE c text;
BEGIN
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
RAISE EXCEPTION '%: % never changes in place', TG_TABLE_NAME, c;
END IF;
END LOOP;
RETURN NEW;
END $$;
CREATE TRIGGER visit_fixed BEFORE UPDATE ON fm.visit
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
FOR EACH ROW EXECUTE FUNCTION fm.tg_fixed_columns('sha256','source_family','kind');
CREATE TRIGGER asset_fixed BEFORE UPDATE ON fm.asset
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
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
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;
@@ -0,0 +1,517 @@
-- 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).
-- Design: fuel-monitoring .context/plans/2026-10-24-fuel-db-baseline-dashboard.md, §4 and §5.1.
BEGIN;
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
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
FROM fm.source_document;
-- The analytical visit set: visits that still carry a live claim. A visit whose claims were all
-- superseded or retracted stays in fm.visit for provenance only and is invisible from here on.
CREATE VIEW fm.v_visit_live AS
SELECT v.* FROM fm.visit v
WHERE EXISTS (SELECT 1 FROM fm.fuel_movement m WHERE m.visit_id = v.visit_id
AND m.claim_status IN ('asserted','reviewed','disputed'))
OR EXISTS (SELECT 1 FROM fm.stock_observation s WHERE s.visit_id = v.visit_id
AND s.claim_status IN ('asserted','reviewed','disputed'))
OR EXISTS (SELECT 1 FROM fm.hour_meter_reading h WHERE h.visit_id = v.visit_id
AND h.claim_status IN ('asserted','reviewed','disputed'));
-- One row per physical movement (event_key; trigger rule 10 keeps kind and tanks equal within it).
-- Value = the best-ranked asserted or reviewed claim. The spread covers every live claim, and a
-- disputed claim makes the event unreviewed. No value = an unquantified movement.
CREATE VIEW fm.v_movement_event AS
SELECT m.visit_id, m.event_key, m.kind, m.from_tank_uid, m.to_tank_uid,
(array_agg(m.litres ORDER BY r.rank, m.movement_id)
FILTER (WHERE m.claim_status IN ('asserted','reviewed') AND m.litres IS NOT NULL))[1] AS litres,
coalesce(max(m.litres) - min(m.litres), 0) AS spread_l,
bool_and(m.claim_status = 'reviewed') AS all_reviewed,
count(*) AS n_claims
FROM fm.fuel_movement m JOIN fm.v_source_rank r ON r.sha256 = m.source_sha256
WHERE m.claim_status IN ('asserted','reviewed','disputed')
GROUP BY m.visit_id, m.event_key, m.kind, m.from_tank_uid, m.to_tank_uid;
-- Quantified site-level deliveries per visit, summed over events (never over claims).
CREATE VIEW fm.v_visit_delivery AS
SELECT visit_id, sum(litres) AS delivered_l, sum(spread_l) AS delivered_spread_l,
bool_and(all_reviewed) AS all_reviewed, count(*) AS n_events
FROM fm.v_movement_event
WHERE kind = 'delivery' AND to_tank_uid IS NULL AND litres IS NOT NULL
GROUP BY visit_id;
-- Every other external movement event (internal transfers cancel out at site level). Between two
-- endpoints they are booked; AT an endpoint visit their order relative to the stock readings is
-- unknown, so the interval is excluded instead of guessed (see v_stock_interval).
CREATE VIEW fm.v_visit_other_movement AS
SELECT visit_id,
sum(litres) FILTER (WHERE kind IN ('withdrawal','drain','spill','loss_reported')) AS out_l,
coalesce(sum(spread_l) FILTER (WHERE kind IN ('withdrawal','drain','spill','loss_reported')), 0) AS out_spread_l,
count(*) AS n_external,
count(*) FILTER (WHERE litres IS NULL OR kind = 'external_in'
OR (kind = 'delivery' AND to_tank_uid IS NOT NULL)) AS n_unquantified,
bool_and(all_reviewed) AS all_reviewed
FROM fm.v_movement_event
WHERE kind <> 'internal_transfer'
AND NOT (kind = 'delivery' AND to_tank_uid IS NULL AND litres IS NOT NULL)
GROUP BY visit_id;
CREATE VIEW fm.v_visit_stock AS -- site-level stock per visit and phase; same claim rules
SELECT s.visit_id, s.phase,
(array_agg(s.litres ORDER BY r.rank, s.stock_obs_id)
FILTER (WHERE s.claim_status IN ('asserted','reviewed') AND s.litres IS NOT NULL))[1] AS litres,
max(s.litres) - min(s.litres) AS spread_l,
max(s.resolution_l) FILTER (WHERE s.litres IS NOT NULL) AS resolution_l,
bool_and(s.claim_status = 'reviewed') AS all_reviewed, -- every live claim, with a number or not
count(*) AS n_claims
FROM fm.stock_observation s JOIN fm.v_source_rank r ON r.sha256 = s.source_sha256
WHERE s.claim_status IN ('asserted','reviewed','disputed') AND s.scope = 'site_total'
GROUP BY s.visit_id, s.phase
HAVING count(*) FILTER (WHERE s.claim_status IN ('asserted','reviewed') AND s.litres IS NOT NULL) > 0;
-- no asserted or reviewed number (only disputed, illegible or placeholder claims): no reading
-- 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
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,
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'
WHEN a.litres - d.delivered_l IS NOT NULL THEN 'derived' END AS arrive_basis,
CASE WHEN b.litres IS NOT NULL THEN b.resolution_l WHEN st.litres IS NOT NULL THEN st.resolution_l
ELSE a.resolution_l END AS arrive_res_l,
CASE WHEN b.litres IS NOT NULL THEN b.spread_l WHEN st.litres IS NOT NULL THEN st.spread_l
ELSE a.spread_l + d.delivered_spread_l END AS arrive_spread_l,
CASE WHEN b.litres IS NOT NULL THEN b.all_reviewed WHEN st.litres IS NOT NULL THEN st.all_reviewed
ELSE a.all_reviewed AND d.all_reviewed END AS arrive_reviewed,
coalesce(a.litres, st.litres, b.litres + d.delivered_l) AS depart_l,
CASE WHEN coalesce(a.litres, st.litres) IS NOT NULL THEN 'stated'
WHEN b.litres + d.delivered_l IS NOT NULL THEN 'derived' END AS depart_basis,
CASE WHEN a.litres IS NOT NULL THEN a.resolution_l WHEN st.litres IS NOT NULL THEN st.resolution_l
ELSE b.resolution_l END AS depart_res_l,
CASE WHEN a.litres IS NOT NULL THEN a.spread_l WHEN st.litres IS NOT NULL THEN st.spread_l
ELSE b.spread_l + d.delivered_spread_l END AS depart_spread_l,
CASE WHEN a.litres IS NOT NULL THEN a.all_reviewed WHEN st.litres IS NOT NULL THEN st.all_reviewed
ELSE b.all_reviewed AND d.all_reviewed END AS depart_reviewed,
a.litres - (b.litres + d.delivered_l) AS after_vs_before_plus_delivered_l -- QA
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_other_movement o ON o.visit_id = v.visit_id
LEFT JOIN fm.v_visit_stock b ON b.visit_id = v.visit_id AND b.phase = 'before_delivery'
LEFT JOIN fm.v_visit_stock a ON a.visit_id = v.visit_id AND a.phase = 'after_delivery'
LEFT JOIN fm.v_visit_stock st ON st.visit_id = v.visit_id AND st.phase = 'standalone';
CREATE VIEW fm.v_stock_interval AS -- consecutive stock points per site; no overlap by construction
WITH pts AS (
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),
pr AS (
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_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,
c.arrive_l AS closing_l, c.arrive_basis AS closing_basis,
coalesce(o.depart_res_l,0) + coalesce(c.arrive_res_l,0) AS endpoint_res_l,
coalesce(o.depart_spread_l,0) + coalesce(c.arrive_spread_l,0) AS endpoint_spread_l,
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.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.holder_uid = o.holder_uid AND c.n = o.n + 1)
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,
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.endpoint_res_l + p.endpoint_spread_l + coalesce(mid.spread_l,0) AS consumed_unc_l,
(p.endpoint_spread_l + coalesce(mid.spread_l,0)) > 0 AS inputs_conflict, -- any disagreement, as v_data_quality
p.endpoints_reviewed AND coalesce(mid.all_reviewed, true) AS inputs_reviewed,
CASE WHEN p.opening_l IS NULL OR p.closing_l IS NULL THEN 'no_opening_or_closing'
WHEN p.n_endpoint_movements > 0 THEN 'endpoint_movement_order_unknown'
WHEN mid.n_unknown > 0 THEN 'unquantified_movement'
WHEN p.days = 0 THEN 'same_day'
WHEN p.opening_l + coalesce(mid.delivered_l,0) - coalesce(mid.out_l,0) - p.closing_l
< -(p.endpoint_res_l + p.endpoint_spread_l + coalesce(mid.spread_l,0)) THEN 'negative'
ELSE 'ok' END AS stock_status
-- mid: live visits strictly between the endpoints. A visit counts only through its live movement
-- events, never through its kind.
FROM pr p
CROSS JOIN LATERAL (
SELECT sum(d.delivered_l) AS delivered_l,
sum(coalesce(d.delivered_spread_l, 0) + coalesce(x.out_spread_l, 0)) AS spread_l,
sum(x.out_l) AS out_l,
bool_and(coalesce(d.all_reviewed, true) AND coalesce(x.all_reviewed, true)) AS all_reviewed,
coalesce(sum(x.n_unquantified), 0) AS n_unknown
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_other_movement x ON x.visit_id = v.visit_id
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.c_on, p.c_seq)) mid;
CREATE VIEW fm.v_visit_reading AS -- one reading per visit and meter, with the claims' spread
SELECT h.visit_id, h.meter_id,
min(h.epoch_id) FILTER (WHERE h.hours IS NOT NULL) AS epoch_id,
count(DISTINCT h.epoch_id) FILTER (WHERE h.hours IS NOT NULL) AS n_epochs,
count(*) FILTER (WHERE h.hours IS NOT NULL AND h.epoch_id IS NULL) AS n_unassigned_epoch,
(array_agg(h.hours ORDER BY r.rank, h.reading_id)
FILTER (WHERE h.claim_status IN ('asserted','reviewed') AND h.hours IS NOT NULL))[1] AS hours,
min(h.hours) AS hours_min, max(h.hours) AS hours_max,
max(h.resolution_h) FILTER (WHERE h.hours IS NOT NULL) AS resolution_h,
bool_or(h.date_basis <> 'stated') FILTER (WHERE h.hours IS NOT NULL) AS date_inferred,
bool_and(h.claim_status = 'reviewed') AS all_reviewed -- every live claim, with a number or not
FROM fm.hour_meter_reading h JOIN fm.v_source_rank r ON r.sha256 = h.source_sha256
WHERE h.claim_status IN ('asserted','reviewed','disputed')
GROUP BY h.visit_id, h.meter_id
HAVING count(*) FILTER (WHERE h.claim_status IN ('asserted','reviewed') AND h.hours IS NOT NULL) > 0;
-- no asserted or reviewed number (only disputed or illegible claims): no reading
-- M2 per stock interval. Only a validated population is summed (CTE sel):
-- * a counter CONFIRMED for a generator placed at the site in the interval, when that generator
-- has exactly one such counter and no idle assumption;
-- * counters not tied to a set (unmapped, or mapping only proposed), only when the one live
-- meter_coverage assumption for the dates names exactly these counters, exactly the generators
-- left uncovered and exactly the idle sets. Such intervals give only the summed-set-hour metric.
-- A counter confirmed for a generator NOT placed at the site in the interval is never summed and
-- excludes the interval. Runtime and L/h are reported only for a valid population.
CREATE VIEW fm.v_lph_interval AS
WITH gen AS ( -- generators placed at the site at any time in the interval; full_cover =
-- one placement spans the whole interval, both endpoint dates included
SELECT i.interval_key, p.asset_uid,
bool_or(daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)')
@> daterange(i.start_on, i.end_on, '[]')) AS full_cover
FROM fm.v_stock_interval i
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'
WHERE daterange(coalesce(p.occupied_from,'-infinity'::date), p.occupied_to, '[)')
&& daterange(i.start_on, i.end_on, '[]')
GROUP BY i.interval_key, p.asset_uid),
pair AS ( -- every counter read at both endpoints, classified
SELECT i.interval_key, m.meter_id,
CASE WHEN m.mapping_status = 'confirmed' THEN m.asset_uid END AS asset_uid,
CASE WHEN m.mapping_status <> 'confirmed' THEN 'not_tied'
WHEN EXISTS (SELECT 1 FROM gen g
WHERE g.interval_key = i.interval_key AND g.asset_uid = m.asset_uid) THEN 'tied'
ELSE 'tied_elsewhere' END AS tie,
(o.n_epochs = 1 AND c.n_epochs = 1 AND o.n_unassigned_epoch = 0 AND c.n_unassigned_epoch = 0
AND o.epoch_id = c.epoch_id) AS same_epoch,
c.hours - o.hours AS runtime_h,
c.hours_min - o.hours_max - o.resolution_h - c.resolution_h AS runtime_low_h,
c.hours_max - o.hours_min + o.resolution_h + c.resolution_h AS runtime_high_h,
o.date_inferred OR c.date_inferred AS date_inferred,
o.all_reviewed AND c.all_reviewed AS reviewed,
(o.hours_max > o.hours_min OR c.hours_max > c.hours_min) AS conflict
FROM fm.v_stock_interval i
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 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)
SELECT g.interval_key, g.asset_uid, x.code, x.status
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.holder_uid = i.holder_uid -- this site's only
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, '[]')),
per_gen AS (
SELECT g.interval_key, g.asset_uid, g.full_cover,
count(DISTINCT p.meter_id) AS n_counters,
count(DISTINCT d.code) > 0 AS is_idle,
coalesce(bool_or(d.status = 'proposed'), false) AS idle_proposed
FROM gen g
LEFT JOIN pair p ON p.interval_key = g.interval_key AND p.asset_uid = g.asset_uid AND p.tie = 'tied'
LEFT JOIN idle d ON d.interval_key = g.interval_key AND d.asset_uid = g.asset_uid
GROUP BY g.interval_key, g.asset_uid, g.full_cover),
cov AS (
SELECT interval_key, count(*) AS n_generators,
count(*) FILTER (WHERE NOT full_cover) AS n_partial,
count(*) FILTER (WHERE n_counters > 1) AS n_double_counted,
count(*) FILTER (WHERE n_counters >= 1 AND is_idle) AS n_metered_and_idle,
count(*) FILTER (WHERE n_counters = 0 AND NOT is_idle) AS n_uncovered,
coalesce(array_agg(asset_uid ORDER BY asset_uid)
FILTER (WHERE n_counters = 0 AND NOT is_idle), '{}') AS uncovered_set,
coalesce(array_agg(asset_uid ORDER BY asset_uid) FILTER (WHERE is_idle), '{}') AS idle_set,
bool_or(idle_proposed) AS idle_proposed
FROM per_gen GROUP BY interval_key),
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
FROM pair WHERE tie = 'not_tied' GROUP BY interval_key),
cova AS ( -- live meter_coverage assumptions with their exact members
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)
FILTER (WHERE m.member_role = 'counter'), '{}') AS meters,
coalesce(array_agg(m.asset_uid ORDER BY m.asset_uid)
FILTER (WHERE m.member_role = 'covered_generator'), '{}') AS covered,
coalesce(array_agg(m.asset_uid ORDER BY m.asset_uid)
FILTER (WHERE m.member_role = 'idle_generator'), '{}') AS idle_set
FROM fm.assumption x JOIN fm.assumption_member m ON m.assumption_id = x.assumption_id
WHERE x.topic = 'meter_coverage' AND x.status IN ('proposed','accepted')
GROUP BY x.assumption_id),
unm AS ( -- at most one live coverage assumption per site and date (EXCLUDE)
SELECT l.interval_key, cardinality(l.meters) AS n_unmapped, c.code AS cov_code, c.status AS cov_status,
c.covered AS cov_covered,
coalesce(c.meters = l.meters
AND c.covered = coalesce(cv.uncovered_set, '{}')
AND c.idle_set = coalesce(cv.idle_set, '{}')
AND cardinality(c.meters) = cardinality(c.covered), false) AS cov_matches
FROM loose l
JOIN fm.v_stock_interval i USING (interval_key)
LEFT JOIN cov cv USING (interval_key)
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, '[]')),
sel AS ( -- the validated population; nothing else is summed
SELECT p.* FROM pair p
JOIN per_gen g ON g.interval_key = p.interval_key AND g.asset_uid = p.asset_uid
WHERE p.tie = 'tied' AND g.n_counters = 1 AND NOT g.is_idle
UNION ALL
SELECT p.* FROM pair p JOIN unm u ON u.interval_key = p.interval_key
WHERE p.tie = 'not_tied' AND u.cov_matches),
agg AS (
SELECT interval_key, count(*) AS n_meters, sum(runtime_h) AS runtime_h,
sum(runtime_low_h) AS runtime_low_h, sum(runtime_high_h) AS runtime_high_h,
bool_and(same_epoch) AS same_epoch, bool_and(runtime_h >= 0) AS monotonic,
max(runtime_h) AS max_meter_h, bool_or(date_inferred) AS date_inferred,
bool_and(reviewed) AS runtime_reviewed, bool_or(conflict) AS runtime_conflict,
string_agg(coalesce(asset_uid, 'meter:' || meter_id), ','
ORDER BY coalesce(asset_uid, 'meter:' || meter_id)) AS population_key
FROM sel GROUP BY interval_key),
rd AS ( -- what was read at both ends, before validation
SELECT interval_key, count(*) FILTER (WHERE tie = 'tied_elsewhere') AS n_tied_elsewhere
FROM pair GROUP BY interval_key),
pv AS ( -- valid = the summed population covers every placed generator exactly once
SELECT rd.interval_key,
coalesce(cv.n_generators > 0 AND rd.n_tied_elsewhere = 0 AND cv.n_partial = 0
AND cv.n_double_counted = 0 AND cv.n_metered_and_idle = 0
AND CASE WHEN u.interval_key IS NULL THEN cv.n_uncovered = 0 ELSE u.cov_matches END
AND a.n_meters >= 1, false) AS population_valid
FROM rd LEFT JOIN cov cv USING (interval_key) LEFT JOIN unm u USING (interval_key)
LEFT JOIN agg a USING (interval_key)),
x AS (
SELECT i.*,
coalesce(pv.population_valid, false) AS population_valid,
a.n_meters, u.cov_code,
CASE WHEN pv.population_valid THEN a.runtime_h END AS runtime_h,
CASE WHEN pv.population_valid THEN a.runtime_low_h END AS runtime_low_h,
CASE WHEN pv.population_valid THEN a.runtime_high_h END AS runtime_high_h,
CASE WHEN pv.population_valid THEN -- counters not tied to a set also name the sets they cover
(SELECT string_agg(k, ',' ORDER BY k)
FROM unnest(string_to_array(a.population_key, ',')
|| CASE WHEN u.cov_matches THEN array(SELECT 'covers:' || g FROM unnest(u.cov_covered) g)
ELSE '{}'::text[] END) k) END AS population_key,
a.date_inferred, a.runtime_reviewed, a.runtime_conflict,
coalesce(cv.n_generators, 0) AS n_generators, coalesce(u.n_unmapped, 0) AS n_unmapped,
coalesce(cv.idle_proposed, false)
OR (coalesce(u.cov_matches, false) AND u.cov_status = 'proposed') AS depends_on_proposed,
concat_ws('; ',
CASE WHEN i.stock_status <> 'ok' THEN 'stock interval: ' || i.stock_status END,
CASE WHEN rd.interval_key IS NULL THEN 'no timer reading at both ends' END,
CASE WHEN coalesce(cv.n_generators, 0) = 0 THEN 'no generator placed at the site' END,
CASE WHEN cv.n_partial > 0
THEN 'a generator was placed at or removed from the site within the interval' END,
CASE WHEN rd.n_tied_elsewhere > 0
THEN 'a counter is confirmed for a generator not placed here in the interval' END,
CASE WHEN cv.n_double_counted > 0 THEN 'two counters for one generator' END,
CASE WHEN cv.n_metered_and_idle > 0 THEN 'a generator is both metered and assumed idle' END,
CASE WHEN u.interval_key IS NULL AND cv.n_uncovered > 0
THEN 'a generator has neither a counter nor an idle assumption' END,
CASE WHEN u.interval_key IS NOT NULL AND u.cov_code IS NULL
THEN 'counters not tied to sets, and no meter-coverage assumption for the whole interval' END,
CASE WHEN u.cov_code IS NOT NULL AND NOT u.cov_matches
THEN 'meter-coverage assumption ' || u.cov_code
|| ' does not name exactly these counters, uncovered sets and idle sets' END,
CASE WHEN NOT a.same_epoch THEN 'readings not in one meter epoch' END,
CASE WHEN NOT a.monotonic THEN 'timer went backwards' END,
CASE WHEN a.max_meter_h > 24 * i.days THEN 'over 24 h/day on one meter' END,
CASE WHEN a.runtime_h = 0 AND i.consumed_l > 0 THEN 'fuel used with zero runtime' END,
CASE WHEN a.runtime_h = 0 AND i.consumed_l <= 0 THEN 'no operation in the interval' END,
CASE WHEN i.days < 7 THEN 'shorter than 7 days' END) AS exclusion_reasons
FROM fm.v_stock_interval i
LEFT JOIN rd USING (interval_key) LEFT JOIN agg a USING (interval_key)
LEFT JOIN cov cv USING (interval_key) LEFT JOIN unm u USING (interval_key)
LEFT JOIN pv USING (interval_key))
SELECT x.*,
CASE WHEN NOT population_valid THEN NULL
WHEN n_unmapped = 0 AND n_meters = 1 THEN 'apparent_l_per_generator_h'
ELSE 'apparent_l_per_set_h' END AS metric,
CASE WHEN runtime_h > 0 THEN consumed_l / runtime_h END AS l_per_h,
CASE WHEN runtime_high_h > 0 THEN (consumed_l - consumed_unc_l) / runtime_high_h END AS l_per_h_low,
CASE WHEN runtime_low_h > 0 THEN (consumed_l + consumed_unc_l) / runtime_low_h END AS l_per_h_high,
-- NULL beside a non-NULL l_per_h = unbounded
CASE WHEN exclusion_reasons <> '' OR NOT population_valid THEN 'excluded'
WHEN depends_on_proposed THEN 'conditional' ELSE 'eligible' END AS eligibility,
CASE WHEN opening_basis = 'stated' AND closing_basis = 'stated' AND inputs_reviewed AND NOT inputs_conflict
AND NOT date_inferred AND runtime_reviewed AND NOT runtime_conflict THEN 'G1' ELSE 'G2' END AS evidence_grade
FROM x;
-- 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.
CREATE VIEW fm.v_m4_interval AS
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.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.inputs_reviewed AND NOT i.inputs_conflict THEN 'G1' ELSE 'G2' END AS evidence_grade
FROM fm.v_stock_interval i;
-- Everything an interval's value and eligibility rest on, as sorted lists, with their hashes. Claims
-- are listed in every status, so a review, supersession or retraction changes the hash. `baseline
-- propose` snapshots both lists; fm.v_baseline_validity compares them with today's state.
CREATE VIEW fm.v_interval_dependency AS
WITH span AS ( -- the endpoint visits and every visit between them, live or not
SELECT i.interval_key, v.visit_id
FROM fm.v_stock_interval i
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 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) <= (c.visit_on, c.seq_in_day)),
stock_deps AS (
SELECT s.interval_key, jsonb_agg(d.item ORDER BY d.item) AS deps
FROM span s CROSS JOIN LATERAL (
SELECT 'stock:' || x.source_key || ':' || x.assertion_sha256 || ':' || x.claim_status AS item
FROM fm.stock_observation x WHERE x.visit_id = s.visit_id
UNION ALL
SELECT 'movement:' || x.event_key || ':' || x.assertion_sha256 || ':' || x.claim_status
FROM fm.fuel_movement x WHERE x.visit_id = s.visit_id) d
GROUP BY s.interval_key),
runtime_deps AS (
SELECT i.interval_key, jsonb_agg(d.item ORDER BY d.item) AS deps
FROM fm.v_stock_interval i CROSS JOIN LATERAL (
SELECT 'reading:' || x.meter_id || ':' || x.assertion_sha256 || ':' || x.claim_status
|| ':' || coalesce(x.epoch_id::text, '-') AS item
FROM fm.hour_meter_reading x WHERE x.visit_id IN (i.open_visit_id, i.close_visit_id)
UNION ALL
SELECT 'meter:' || m.meter_id || ':' || m.mapping_status || ':' || coalesce(m.asset_uid, '-')
FROM fm.runtime_meter m WHERE m.holder_uid = i.holder_uid
UNION ALL
SELECT 'epoch:' || e.meter_id || ':' || e.epoch_id || ':' || coalesce(e.starts_on::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
WHERE m.holder_uid = i.holder_uid
UNION ALL -- placements at this site, and anywhere of the assets its meters name
SELECT 'placement:' || p.asset_uid || ':' || coalesce(p.holder_uid, p.place_uid) || ':' || coalesce(p.occupied_from::text, '-')
|| ':' || coalesce(p.occupied_to::text, '-')
FROM fm.asset_placement p
WHERE (p.holder_uid = i.holder_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, '[)')
&& daterange(i.start_on, i.end_on, '[]')
UNION ALL
SELECT 'assumption:' || x.code || ':' || x.fingerprint || ':' || x.status
FROM fm.assumption x
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
GROUP BY i.interval_key)
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,
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
FROM fm.v_stock_interval i
LEFT JOIN stock_deps s USING (interval_key) LEFT JOIN runtime_deps r USING (interval_key);
-- Does each version still rest on what it was computed from? An input whose interval no longer
-- exists, or whose dependency hash differs, makes the whole version "inputs changed".
CREATE VIEW fm.v_baseline_validity AS
SELECT b.baseline_id,
count(bi.baseline_id) > 0
AND coalesce(bool_and(d.interval_key IS NOT NULL
AND d.stock_dep_sha256 = bi.stock_dep_sha256
AND (b.metric = 'fuel_l_per_calendar_day'
OR d.runtime_dep_sha256 = bi.runtime_dep_sha256)), false) AS inputs_unchanged
FROM fm.baseline_version b
LEFT JOIN fm.baseline_input bi ON bi.baseline_id = b.baseline_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
GROUP BY b.baseline_id, b.metric;
-- Consecutive readings per meter (live visits), giving hours per day: R1 / public.v_runtime.
CREATE VIEW fm.v_runtime_pair AS
WITH r AS (
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,
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)
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,
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,
(o.n_epochs = 1 AND c.n_epochs = 1 AND o.n_unassigned_epoch = 0 AND c.n_unassigned_epoch = 0
AND o.epoch_id = c.epoch_id) AS same_epoch,
c.hours >= o.hours AS monotonic,
c.hours - o.hours > 24 * (c.visit_on - o.visit_on) AS over_24h_per_day,
o.all_reviewed AND c.all_reviewed AS reviewed
FROM r o JOIN r c ON c.meter_id = o.meter_id AND c.n = o.n + 1;
-- M3, modelled L/h: L per calendar day over the usable intervals that lie wholly inside the
-- 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).
CREATE VIEW fm.v_modelled_lph AS
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,
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.high_num, 0) AS modelled_l_per_h_low,
m4.fuel_l / NULLIF(m4.days, 0) / NULLIF(x.low_num, 0) AS modelled_l_per_h_high
FROM fm.assumption x
CROSS JOIN LATERAL (
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
FROM fm.v_m4_interval i
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
WHERE x.topic = 'runtime_hours_per_day' AND NOT x.derived_from_fuel AND x.status IN ('proposed','accepted')
AND m4.days > 0;
-- Review entries whose fingerprint matches no live row: the value changed after it was reviewed,
-- 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 holder the asset was
-- 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
WITH subj AS (
SELECT operator_uid, 'claim' AS kind, source_key AS subject_key, assertion_sha256, holder_uid,
claim_status IN ('asserted','reviewed','disputed') AS live
FROM fm.fuel_movement
UNION ALL SELECT operator_uid, 'claim', source_key, assertion_sha256, holder_uid, claim_status IN ('asserted','reviewed','disputed')
FROM fm.stock_observation
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
UNION ALL SELECT operator_uid, 'claim', source_key, assertion_sha256, holder_uid, claim_status IN ('asserted','reviewed','disputed')
FROM fm.electricity_bill
UNION ALL SELECT operator_uid, 'claim', source_key, assertion_sha256, holder_uid, claim_status IN ('asserted','reviewed','disputed')
FROM fm.imported_calculation
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
UNION ALL SELECT operator_uid, 'claim', source_key, assertion_sha256, holder_uid, claim_status IN ('asserted','reviewed','disputed')
FROM fm.runtime_interval
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
UNION ALL SELECT l.operator_uid, 'location', l.source_key, l.assertion_sha256, hp.holder_uid, l.acceptance IN ('pending','accepted','rejected')
FROM fm.place_location l LEFT JOIN fm.holder_place hp ON hp.place_uid = l.place_uid
UNION ALL SELECT operator_uid, 'assumption', code, fingerprint, holder_uid, status <> 'superseded'
FROM fm.assumption)
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.holder_uid
FROM fm.review r
LEFT JOIN LATERAL (SELECT DISTINCT s.holder_uid FROM subj s
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 l.live AND l.operator_uid = r.operator_uid AND l.kind = r.subject_kind
AND l.subject_key = r.subject_key AND l.assertion_sha256 = r.assertion_sha256);
-- Visits kept for provenance only: every claim superseded or retracted.
CREATE VIEW fm.v_visit_provenance_only AS
SELECT v.* FROM fm.visit v
WHERE NOT EXISTS (SELECT 1 FROM fm.v_visit_live l WHERE l.visit_id = v.visit_id);
COMMIT;
@@ -0,0 +1,639 @@
-- 0003_bi_views.sql: the views Superset reads (schema `public`), plus
-- 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 fuel_ro never reads `fm` (the catalog
-- 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).
-- Design: fuel-monitoring .context/plans/2026-10-24-fuel-db-baseline-dashboard.md, §5.2 and §6.
BEGIN;
SET LOCAL search_path = fm, pg_catalog, public;
-- Status, estimate and method come from ONE baseline_version, never from ad-hoc pooling of intervals
-- (exploratory interval figures stay on the Baseline & Evidence tab):
-- approved : the latest approved KPI version; a decision is recorded. The only state that says "approved".
-- If its inputs changed since, it stays approved and baseline_validity_note says so.
-- eligible : no approval; the latest proposed KPI version passed BP-1 when `baseline propose`
-- computed it from its inputs (meets_policy), and those inputs are unchanged today.
-- provisional : no approval; a proposed version below BP-1, or usable intervals but no version yet
-- (then no estimate is shown).
-- insufficient_evidence: none of the above (today: every site, since there is no runtime).
CREATE VIEW fm.v_site_baseline_status AS
WITH cur AS (
SELECT DISTINCT ON (b.holder_uid) b.*
FROM fm.baseline_version b
WHERE b.status IN ('approved','proposed')
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
ORDER BY b.holder_uid, (b.status = 'approved') DESC, b.approved_on DESC NULLS LAST, b.computed_at DESC),
ev AS (
SELECT holder_uid, count(*) FILTER (WHERE eligibility <> 'excluded') AS n_usable
FROM fm.v_lph_interval GROUP BY holder_uid)
SELECT s.holder_uid,
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' OR coalesce(ev.n_usable, 0) > 0 THEN 'provisional'
ELSE 'insufficient_evidence' END AS baseline_status,
CASE WHEN cur.baseline_id IS NOT NULL AND NOT coalesce(val.inputs_unchanged, false)
THEN CASE cur.status WHEN 'approved' THEN 'Inputs changed since approval: review needed'
ELSE 'Inputs changed since computation: re-propose' END END AS baseline_validity_note,
cur.code AS baseline_code,
CASE WHEN cur.status = 'approved' OR val.inputs_unchanged THEN cur.value END AS lph_estimate,
CASE WHEN cur.status = 'approved' OR val.inputs_unchanged THEN cur.range_low END AS range_low,
CASE WHEN cur.status = 'approved' OR val.inputs_unchanged THEN cur.range_high END AS range_high,
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,
coalesce(ev.n_usable, 0) AS n_usable_intervals
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;
-- One row per holder, every status: the map dataset and the single source of site-level fields.
-- 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
WITH alias AS ( -- the label the customer's people use
SELECT a.holder_uid, a.label FROM fm.holder_alias a
JOIN fm.v_holder_current z ON z.holder_uid = a.holder_uid
JOIN fm.operator o ON o.operator_uid = z.operator_uid
WHERE a.issuer = o.label_issuer AND a.is_primary
AND daterange(coalesce(a.valid_from, '-infinity'::date), a.valid_to, '[)') @> z.today),
pp AS ( -- the customer's one programme
SELECT p.holder_uid, p.status FROM fm.participation p
JOIN fm.v_holder_current z ON z.holder_uid = p.holder_uid
JOIN fm.operator o ON o.operator_uid = z.operator_uid
WHERE p.programme = o.programme AND daterange(p.valid_from, p.valid_to, '[)') @> z.today),
-- alias and pp are at most one row per holder: the exclusion constraints forbid overlapping primary
-- labels per (holder, issuer) and overlapping periods per (holder, programme).
loc AS (SELECT z.holder_uid, l.latitude, l.longitude, l.basis, l.precision_m
FROM fm.place_location l JOIN fm.v_holder_current z ON z.place_uid = l.place_uid
WHERE l.acceptance = 'accepted'),
loc_any AS (SELECT z.holder_uid, bool_or(l.acceptance = 'pending') AS has_pending
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(alias.label, s.holder_uid) || ' - ' || coalesce(s.name, 'name not recorded')
|| ' - ' || s.holder_uid)::varchar(170) AS site_label, -- the one filter value
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,
loc.latitude::numeric(9,6) AS latitude, loc.longitude::numeric(9,6) AS longitude, -- NULL unless 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,
loc.basis::varchar(20) AS location_basis, loc.precision_m::integer AS location_precision_m,
bs.baseline_status::varchar(24) AS baseline_status,
(CASE bs.baseline_status WHEN 'approved' THEN 'Approved baseline'
WHEN 'eligible' THEN 'Eligible - not yet approved' WHEN 'provisional' THEN 'Provisional'
ELSE 'Insufficient evidence' END)::varchar(40) AS baseline_status_label,
bs.baseline_validity_note::varchar(60) AS baseline_validity_note,
round(bs.lph_estimate, 2)::numeric(6,2) AS lph_estimate,
(CASE bs.lph_metric WHEN 'apparent_l_per_generator_h' THEN 'Stock balance per generator running hour'
WHEN 'apparent_l_per_set_h' THEN 'Stock balance per summed generator-hour'
WHEN 'apparent_l_per_site_dg_h' THEN 'Stock balance per hour with any generator running'
WHEN 'measured_generator_l_per_h' THEN 'Measured engine fuel per running hour'
ELSE CASE WHEN bs.n_usable_intervals > 0 THEN 'Not available: no baseline version proposed'
ELSE 'Not available: no runtime evidence' END END)::varchar(60) AS lph_method,
bs.lph_window_start, bs.lph_window_end, bs.conditions::varchar(200) AS baseline_conditions,
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,
latest.latest_record_on
FROM fm.holder s
JOIN fm.v_holder_current z ON z.holder_uid = s.holder_uid
LEFT JOIN fm.place pl ON pl.place_uid = z.place_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, site_label and operator_uid, so one
-- 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.
-- S2, R3: one row per site and measure; the rows stay when a value is not established.
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
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 || ')'
|| CASE WHEN coalesce(val.inputs_unchanged, false) THEN ''
WHEN b.status = 'approved' THEN '; inputs changed since approval: review needed'
ELSE '; inputs changed since computation: re-propose' END AS state
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),
ninp AS (SELECT baseline_id, count(*) FILTER (WHERE included) AS n FROM fm.baseline_input GROUP BY baseline_id),
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 holder_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.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.holder_uid),
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.holder_uid),
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 holder_uid), -- values never from disputed
lday AS ( -- the L/day reference: the latest approved, else proposed, L/day version
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
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')
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, s.operator_uid,
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,
(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
FROM public.v_site s
LEFT JOIN cur ON cur.holder_uid = s.site_uid
LEFT JOIN ninp ON ninp.baseline_id = cur.baseline_id
LEFT JOIN m4 ON m4.holder_uid = s.site_uid
LEFT JOIN span ON span.holder_uid = s.site_uid
LEFT JOIN del ON del.holder_uid = s.site_uid
LEFT JOIN rdg ON rdg.holder_uid = s.site_uid
LEFT JOIN kwh ON kwh.holder_uid = s.site_uid
LEFT JOIN lday ON lday.holder_uid = s.site_uid
CROSS JOIN LATERAL (VALUES
(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),
(2, 'Matched runtime', CASE WHEN cur.shown THEN cur.runtime_h END, 'h', 'calculated', cur.window_start, cur.window_end,
'generator running hours in the same intervals' || coalesce(cur.state, '')),
(3, 'Runtime per day', CASE WHEN cur.shown THEN cur.runtime_h / NULLIF(cur.matched_days, 0) END, 'h/day', 'calculated',
cur.window_start, cur.window_end, 'matched runtime / matched days' || coalesce(cur.state, '')),
(4, 'Intervals used', CASE WHEN cur.shown THEN ninp.n::numeric END, 'intervals', 'calculated', cur.window_start, cur.window_end,
'included inputs of the L/h baseline version' || coalesce(cur.state, '')),
(5, 'Fuel per calendar day, whole record', m4.fuel_l / NULLIF(m4.days, 0), 'L/day', 'calculated',
m4.first_on, m4.last_on, 'stock balance over all usable intervals / calendar days; not per running hour'),
(6, 'L/day reference (baseline version)',
CASE WHEN lday.status = 'approved' OR lday.inputs_unchanged THEN lday.value END, 'L/day', 'calculated',
lday.window_start, lday.window_end,
CASE WHEN lday.code IS NULL THEN 'no L/day baseline version'
ELSE 'version ' || lday.code || ' (' || lday.status || '), fixed window; not per running hour'
|| CASE WHEN lday.inputs_unchanged THEN ''
WHEN lday.status = 'approved' THEN '; inputs changed since approval: review needed'
ELSE '; inputs changed since computation: re-propose' END END),
(7, 'Days covered by usable stock intervals', m4.days::numeric, 'days', 'calculated', m4.first_on, m4.last_on,
'compare with the record span'),
(8, 'Record span', (span.last_on - span.first_on)::numeric, 'days', 'recorded', span.first_on, span.last_on,
'first to last live record'),
(9, 'Deliveries recorded', coalesce(del.n, 0)::numeric, 'deliveries', 'recorded', span.first_on, span.last_on,
'one per delivery event'),
(10, 'Timer readings', coalesce(rdg.n, 0)::numeric, 'readings', 'recorded', NULL::date, NULL::date,
CASE WHEN coalesce(rdg.n, 0) = 0 THEN 'no hour-meter readings' END),
(11, 'Grid energy', kwh.kwh, 'kWh', 'recorded', kwh.first_m, kwh.last_m,
CASE WHEN coalesce(kwh.n_kwh, 0) = 0
THEN 'no kWh records; bills are cost only and are not converted to fuel or hours' END)
) AS x(ord, measure, value, unit, kind, window_start, window_end, note);
-- R2: timer readings as recorded (no rows in v1).
CREATE VIEW public.v_hour_meter_evidence AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
m.source_label::varchar(40) AS meter_label,
(CASE WHEN m.asset_uid IS NULL THEN 'unmapped'
ELSE m.asset_uid || ' (' || m.mapping_status || ')' END)::varchar(60) AS mapped_set,
v.visit_on AS reading_date, h.date_basis::varchar(30) AS date_basis,
round(h.hours, 1)::numeric(9,1) AS hours, h.reading_state::varchar(16) AS reading_state,
h.source_field::varchar(16) AS source_field, e.epoch_no::integer AS epoch_no,
h.claim_status::varchar(12) AS claim_status
FROM fm.hour_meter_reading h
JOIN fm.runtime_meter m ON m.meter_id = h.meter_id
JOIN fm.visit v ON v.visit_id = h.visit_id
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
WHERE h.claim_status IN ('asserted','reviewed','disputed');
-- B5: assumption versions in force or proposed; a coverage assumption lists its members.
CREATE VIEW public.v_assumption AS
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.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.applies_from, x.applies_to, x.basis::varchar(20) AS basis, x.derived_from_fuel,
x.status::varchar(12) AS status, x.accepted_on, x.decision_ref::varchar(12) AS decision_ref,
x.rationale::varchar(400) AS rationale, x.contradicted_by::varchar(200) AS contradicted_by,
mem.members::varchar(400) AS members
FROM fm.assumption x
JOIN public.v_site s ON s.site_uid = x.holder_uid
LEFT JOIN LATERAL (
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
FROM fm.assumption_member m WHERE m.assumption_id = x.assumption_id) mem ON true
WHERE x.status <> 'superseded';
-- B1: baseline versions, with whether their inputs are unchanged today.
CREATE VIEW public.v_baseline_version AS
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.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.range_high, 3)::numeric(8,3) AS range_high,
(CASE WHEN b.metric = 'fuel_l_per_calendar_day' THEN 'L/day' ELSE 'L/h' END)::varchar(8) AS unit,
b.window_start, b.window_end, round(b.fuel_l, 1)::numeric(10,1) AS fuel_l,
round(b.runtime_h, 1)::numeric(10,1) AS runtime_h, b.matched_days::integer AS matched_days,
b.policy_version::varchar(10) AS policy_version, b.meets_policy, b.policy_check::varchar(300) AS policy_check,
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.decision_ref::varchar(12) AS decision_ref, coalesce(val.inputs_unchanged, false) AS inputs_unchanged,
(b.computed_at AT TIME ZONE z.timezone)::timestamp(0) AS computed_at
FROM fm.baseline_version b
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;
-- B2: the inputs of each version, with dependency drift since computation.
CREATE VIEW public.v_baseline_input AS
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,
(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,
round(bi.fuel_l, 1)::numeric(10,1) AS fuel_l, round(bi.runtime_h, 1)::numeric(10,1) AS runtime_h,
bi.days::integer AS days, bi.eligibility_at_computation::varchar(12) AS eligibility_at_computation,
bi.evidence_grade_at_computation::varchar(2) AS evidence_grade_at_computation,
(d.interval_key IS NULL OR d.stock_dep_sha256 <> bi.stock_dep_sha256
OR (b.metric <> 'fuel_l_per_calendar_day'
AND d.runtime_dep_sha256 IS DISTINCT FROM bi.runtime_dep_sha256)) AS dependencies_changed
FROM fm.baseline_input bi
JOIN fm.baseline_version b ON b.baseline_id = bi.baseline_id
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;
-- B7: documents the site's figures rest on (the document itself is never served).
CREATE VIEW public.v_evidence AS
WITH cites AS (
SELECT holder_uid, source_sha256, 'fuel movement' AS record_type
FROM fm.fuel_movement WHERE claim_status IN ('asserted','reviewed','disputed')
UNION SELECT holder_uid, source_sha256, 'stock reading'
FROM fm.stock_observation WHERE claim_status IN ('asserted','reviewed','disputed')
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
WHERE h.claim_status IN ('asserted','reviewed','disputed')
UNION SELECT holder_uid, source_sha256, 'electricity bill'
FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed','disputed')
UNION SELECT holder_uid, source_sha256, 'imported calculation'
FROM fm.imported_calculation WHERE holder_uid IS NOT NULL AND claim_status IN ('asserted','reviewed','disputed')
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
WHERE e.source_sha256 IS NOT NULL AND e.claim_status IN ('asserted','reviewed','disputed')
UNION SELECT holder_uid, source_sha256, 'assumption'
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, s.operator_uid,
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,
string_agg(c.record_type, ', ' ORDER BY c.record_type)::varchar(200) AS used_for,
d.contains_personal_data
FROM cites c
JOIN fm.source_document d ON d.sha256 = c.source_sha256
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, s.operator_uid,
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.
CREATE VIEW public.v_delivery AS
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,
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.record_ref::varchar(60) AS source_ref,
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
JOIN fm.v_visit_live v ON v.visit_id = e.visit_id
JOIN public.v_site s ON s.site_uid = v.holder_uid
CROSS JOIN LATERAL ( -- price and reference of the best-ranked claim;
-- 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,
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
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
WHERE e.kind = 'delivery';
-- F2: stock readings as recorded, plus after-delivery points derived as before + delivered.
CREATE VIEW public.v_stock_reading AS
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,
(CASE st.phase WHEN 'before_delivery' THEN 'before delivery' WHEN 'after_delivery' THEN 'after delivery'
ELSE 'stock check' END)::varchar(20) AS reading_type,
round(st.litres, 1)::numeric(8,1) AS litres, 'recorded'::varchar(12) AS value_kind,
round(st.spread_l, 1)::numeric(8,1) AS spread_l, st.all_reviewed AS reviewed
FROM fm.v_visit_stock st
JOIN fm.v_visit_live v ON v.visit_id = st.visit_id
JOIN public.v_site s ON s.site_uid = v.holder_uid
UNION ALL
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),
round(p.depart_l, 1)::numeric(8,1), 'calculated'::varchar(12),
round(p.depart_spread_l, 1)::numeric(8,1), p.depart_reviewed
FROM fm.v_stock_point p
JOIN public.v_site s ON s.site_uid = p.holder_uid
WHERE p.depart_basis = 'derived';
-- 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).
CREATE VIEW public.v_interval AS
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),
inb AS ( -- the inputs of each site's KPI version
SELECT bi.open_visit_id, bi.close_visit_id, bi.included
FROM fm.baseline_input bi
JOIN fm.baseline_version b ON b.baseline_id = bi.baseline_id
JOIN cur ON cur.baseline_code = b.code),
cond AS ( -- assumptions a valid interval depends on
SELECT l.interval_key, string_agg(DISTINCT x.code, ', ') AS codes
FROM fm.v_lph_interval l
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 daterange(x.applies_from, x.applies_to, '[]') @> daterange(l.start_on, l.end_on, '[]')
WHERE l.population_valid
GROUP BY l.interval_key)
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,
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.deliveries_between_l, 1)::numeric(9,1) AS deliveries_l,
round(l.out_l, 1)::numeric(9,1) AS withdrawals_l,
round(l.closing_l, 1)::numeric(9,1) AS closing_l, l.closing_basis::varchar(8) AS closing_basis,
round(l.consumed_l, 1)::numeric(9,1) AS fuel_used_l, round(l.consumed_unc_l, 1)::numeric(9,1) AS fuel_used_unc_l,
round(l.consumed_l / NULLIF(l.days, 0), 1)::numeric(8,1) AS fuel_l_per_day,
l.stock_status::varchar(40) AS stock_status,
round(l.runtime_h, 1)::numeric(9,1) AS runtime_h,
round(l.runtime_h / NULLIF(l.days, 0), 2)::numeric(6,2) AS runtime_h_per_day,
coalesce(l.n_meters, 0)::integer AS n_meters, l.metric::varchar(40) AS metric,
l.population_key::varchar(200) AS population_key, -- pool only within one metric and population
round(l.l_per_h, 3)::numeric(8,3) AS l_per_h, round(l.l_per_h_low, 3)::numeric(8,3) AS l_per_h_low,
round(l.l_per_h_high, 3)::numeric(8,3) AS l_per_h_high,
l.eligibility::varchar(12) AS eligibility, nullif(l.exclusion_reasons, '')::varchar(600) AS exclusion_reasons,
l.evidence_grade::varchar(2) AS evidence_grade, cond.codes::varchar(200) AS conditions,
coalesce(inb.included, false) AS in_current_baseline,
coalesce(cur.baseline_status = 'approved' AND l.metric = cur.metric
AND l.population_key = cur.population_key, false) AS baseline_comparable,
(CASE WHEN cur.baseline_status = 'approved' AND l.metric = cur.metric
AND l.population_key = cur.population_key
THEN cur.lph_estimate END)::numeric(8,3) AS current_baseline_l_per_h
FROM fm.v_lph_interval l
JOIN public.v_site s ON s.site_uid = l.holder_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 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
-- and h/day only for a valid pair (one epoch, not decreasing, at most 24 h per day).
CREATE VIEW public.v_runtime AS
SELECT s.site_uid, s.site_code, s.site_name, s.site_label, s.operator_uid,
m.source_label::varchar(40) AS meter_label,
(CASE WHEN m.asset_uid IS NULL THEN 'unmapped'
ELSE m.asset_uid || ' (' || m.mapping_status || ')' END)::varchar(60) AS mapped_set,
(m.source_label || ': ' || to_char(r.start_on, 'YYYY-MM-DD') || ' to '
|| to_char(r.end_on, 'YYYY-MM-DD'))::varchar(80) AS interval_label,
r.start_on, r.end_on, r.days::integer AS days,
(CASE WHEN v.pair_valid THEN round(r.runtime_h, 1) END)::numeric(9,1) AS runtime_h,
(CASE WHEN v.pair_valid THEN round(r.h_per_day, 2) END)::numeric(6,2) AS h_per_day,
v.pair_valid, r.same_epoch, r.monotonic, r.over_24h_per_day, r.reviewed
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
JOIN fm.runtime_meter m ON m.meter_id = r.meter_id
JOIN public.v_site s ON s.site_uid = r.holder_uid;
-- S3 (Gantt): which periods each kind of evidence covers; local midnight as timestamp(0).
CREATE VIEW public.v_coverage AS
WITH spans AS (
SELECT holder_uid, '1 Stock intervals' AS lane, stock_status AS status, start_on, end_on
FROM fm.v_stock_interval
UNION ALL
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,
r.start_on, r.end_on
FROM fm.v_runtime_pair r JOIN fm.runtime_meter m ON m.meter_id = r.meter_id
UNION ALL
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
UNION ALL
SELECT holder_uid, '4 Electricity bills',
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)
FROM fm.electricity_bill WHERE claim_status IN ('asserted','reviewed','disputed'))
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.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.holder_uid;
-- 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
-- (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
-- 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
-- fm.v_lph_interval) and covered for the whole window.
CREATE VIEW public.v_reconciliation AS
WITH gen AS ( -- one row per interval and generator placed at the site at any time in it
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, '[)')
@> 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,
(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 z.timezone AS t1
FROM fm.v_stock_interval l
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.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'
WHERE daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)')
&& daterange(l.start_on, l.end_on, '[]')
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, z.timezone),
eng AS (
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)) >= extract(epoch FROM g.t1 - g.t0) AS full_cover
FROM gen g JOIN fm.engine_fuel_total t ON t.asset_uid = g.asset_uid
AND t.claim_status IN ('asserted','reviewed')
AND t.started_at >= g.t0 AND t.ended_at <= g.t1
GROUP BY g.interval_key, g.asset_uid, g.t0, g.t1),
tot AS (
SELECT g.interval_key, count(*) AS n_generators,
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
GROUP BY g.interval_key)
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,
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,
round(g.consumed_l, 1)::numeric(9,1) AS stock_balance_l, round(e.engine_l, 1)::numeric(9,1) AS engine_l,
round(e.covered_h, 1)::numeric(9,1) AS covered_h,
round(extract(epoch FROM g.t1 - g.t0) / 3600, 1)::numeric(9,1) AS interval_h,
round(t.engine_all_l, 1)::numeric(9,1) AS engine_all_generators_l,
(t.n_complete = t.n_generators) AS all_generators_metered,
(CASE WHEN t.n_complete = t.n_generators AND g.times_known AND g.stock_status = 'ok'
THEN round(g.consumed_l - t.engine_all_l, 1) END)::numeric(9,1) AS difference_l,
(CASE WHEN t.n_complete = t.n_generators AND g.times_known AND g.stock_status = 'ok'
THEN round(100 * (g.consumed_l - t.engine_all_l) / NULLIF(t.engine_all_l, 0), 1) END)::numeric(7,1) AS difference_pct
FROM gen g
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 public.v_site s ON s.site_uid = g.holder_uid;
-- B6: one row per site and check; rows stay when ok.
CREATE VIEW public.v_data_quality AS
WITH c AS (
SELECT s.holder_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,
(SELECT count(*) FROM fm.v_movement_event e JOIN fm.visit v ON v.visit_id = e.visit_id
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
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
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.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
WHERE p.holder_uid = s.holder_uid AND a.kind = 'generator'
AND daterange(coalesce(p.occupied_from, '-infinity'::date), p.occupied_to, '[)')
@> (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
WHERE m.asset_uid = p.asset_uid AND m.mapping_status = 'confirmed'
AND h.claim_status IN ('asserted','reviewed'))) AS n_gen_no_readings,
(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.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
WHERE m.holder_uid = s.holder_uid AND x.claim_status = 'asserted')
+ (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.holder_uid = s.holder_uid
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.holder_uid = s.holder_uid
AND i.stock_status IN ('negative','same_day')) AS n_neg_same_day,
(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,
(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,
(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.claim_status IN ('asserted','reviewed','disputed')) AS n_bills_no_kwh, -- no usable kWh
(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.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
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.holder_uid = s.holder_uid) AS n_stale,
(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.holder_uid = s.holder_uid
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.holder_uid = s.holder_uid
AND l.exclusion_reasons LIKE '%meter-coverage assumption % does not name%') AS n_cov_mismatch
FROM fm.holder s)
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.status::varchar(10) AS status, x.detail::varchar(300) AS detail
FROM c JOIN public.v_site vs ON vs.site_uid = c.holder_uid
CROSS JOIN LATERAL (VALUES
(1, 'placeholder_rows', 'Placeholder rows',
CASE WHEN c.n_placeholder > 0 THEN 'warning' ELSE 'ok' END,
c.n_placeholder || ' row(s) imported as "no stock reading", never as 0'),
(2, 'claim_conflicts', 'Conflicting claims',
CASE WHEN c.n_conflicts > 0 THEN 'conflict' ELSE 'ok' END,
c.n_conflicts || ' movement or stock reading(s) with disagreeing sources'),
(3, 'runtime_evidence', 'Runtime evidence',
CASE WHEN c.n_readings = 0 THEN 'missing' ELSE 'ok' END,
CASE WHEN c.n_readings = 0 THEN 'no hour-meter readings: no L/h can be calculated'
ELSE c.n_readings || ' timer reading(s)' END),
(4, 'location', 'Location',
CASE vs.location_status WHEN 'accepted' THEN 'ok' WHEN 'awaiting acceptance' THEN 'warning' ELSE 'missing' END,
vs.location_status::text),
(5, 'unmapped_meters', 'Counters not tied to a generator',
CASE WHEN c.n_unmapped > 0 THEN 'warning' ELSE 'ok' END, c.n_unmapped || ' counter(s)'),
(6, 'generators_without_readings', 'Generators without timer readings',
CASE WHEN c.n_gen_no_readings > 0 THEN 'missing' ELSE 'ok' END, c.n_gen_no_readings || ' generator(s) placed now'),
(7, 'unreviewed_claims', 'Claims not yet reviewed against the originals',
CASE WHEN c.n_unreviewed > 0 THEN 'warning' ELSE 'ok' END, c.n_unreviewed || ' claim(s)'),
(8, 'after_vs_before_plus_delivered', 'Stated after-delivery stock differs from before + delivered',
CASE WHEN c.n_after_mismatch > 0 THEN 'conflict' ELSE 'ok' END, c.n_after_mismatch || ' visit(s)'),
(9, 'negative_or_same_day_intervals', 'Negative or same-day stock intervals',
CASE WHEN c.n_neg_same_day > 0 THEN 'warning' ELSE 'ok' END, c.n_neg_same_day || ' interval(s)'),
(10, 'interval_gaps', 'Stock intervals excluded for missing or unordered data',
CASE WHEN c.n_gaps > 0 THEN 'warning' ELSE 'ok' END, c.n_gaps || ' interval(s)'),
(11, 'no_kwh', 'Grid energy (kWh)',
CASE WHEN c.n_bills = 0 OR c.n_bills_no_kwh > 0 THEN 'missing' ELSE 'ok' END,
CASE WHEN c.n_bills = 0 THEN 'no electricity bills'
WHEN c.n_bills_no_kwh > 0 THEN c.n_bills_no_kwh || ' bill(s) without usable kWh (cost only or disputed)'
ELSE 'kWh on every bill' END),
(12, 'transfer_records', 'Transfer, drain or loss records (HD-05)',
CASE WHEN c.n_transfer_records = 0 THEN 'warning' ELSE 'ok' END,
CASE WHEN c.n_transfer_records = 0 THEN 'none recorded: every stock balance assumes no unrecorded movements'
ELSE c.n_transfer_records || ' record(s)' END),
(13, 'baseline_input_drift', 'Baseline inputs changed since computation',
CASE WHEN c.n_drift > 0 THEN 'warning' ELSE 'ok' END, c.n_drift || ' proposed or approved version(s)'),
(14, 'approval_status', 'Baseline approval',
CASE WHEN vs.baseline_status = 'approved' THEN 'ok' ELSE 'warning' END,
vs.baseline_status_label || coalesce(' - ' || vs.baseline_validity_note, '')),
(15, 'stale_reviews', 'Review entries for values changed since',
CASE WHEN c.n_stale > 0 THEN 'warning' ELSE 'ok' END, c.n_stale || ' entr(y/ies)'),
(16, 'provenance_only_visits', 'Visits kept for provenance only',
CASE WHEN c.n_provenance_only > 0 THEN 'warning' ELSE 'ok' END,
c.n_provenance_only || ' visit(s) whose records were all retracted or superseded'),
(17, 'counter_on_unplaced_generator', 'Counters confirmed for a generator not placed here',
CASE WHEN c.n_unplaced_counter > 0 THEN 'conflict' ELSE 'ok' END, c.n_unplaced_counter || ' interval(s)'),
(18, 'coverage_assumption_mismatch', 'Meter-coverage assumption does not match the counters read',
CASE WHEN c.n_cov_mismatch > 0 THEN 'conflict' ELSE 'ok' END, c.n_cov_mismatch || ' interval(s)')
) AS x(ord, code, label, status, detail);
-- B10: specifications, labelled as such; live rows only. A disputed specification is listed with its
-- status and without its value.
CREATE VIEW public.v_spec_reference AS
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(20) AS asset_kind,
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,
(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_text END)::varchar(120) AS value_text, e.unit::varchar(20) AS unit,
e.basis::varchar(30) AS basis, e.applicability::varchar(12) AS applicability,
e.applicability_note::varchar(300) AS applicability_note,
'specification, not measured'::varchar(30) AS value_kind, e.claim_status::varchar(12) AS claim_status
FROM fm.equipment_spec e
JOIN fm.asset a ON a.asset_uid = e.asset_uid
JOIN (SELECT DISTINCT ON (asset_uid, holder_uid) asset_uid, holder_uid, slot_label
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.holder_uid
WHERE e.claim_status IN ('asserted','reviewed','disputed');
-- M3 sensitivity table, reference tab only (shown only once Q9 approves a runtime assumption).
CREATE VIEW public.v_modelled_lph AS
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.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.matched_days::integer AS matched_days, round(m.l_per_day, 1)::numeric(7,1) AS l_per_day,
round(m.modelled_l_per_h, 2)::numeric(6,2) AS modelled_l_per_h,
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,
'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.holder_uid;
-- R4: electricity cost per bill month; cost context only, never joined into fuel metrics. A
-- disputed bill stays listed, without amount or kWh.
CREATE VIEW public.v_electricity_cost AS
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,
(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,
(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
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');
-- B8: operator workbook calculations and our assessment; live rows only. A disputed transcription is
-- listed with its status and without its formula or value.
CREATE VIEW public.v_imported_calculation AS
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,
(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,
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,
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.holder_uid
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;
@@ -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;
@@ -0,0 +1,127 @@
# fuel-db migrations
**Status: DRAFT, not applied.** Nothing here runs automatically: `deploy.sh` never touches
fuel-db, and the compose file mounts only `../init/01-roles.sh`. A person applies these files by
hand, once the owner gives the go-ahead (phase P3 of the design).
The design and its rationale live in the fuel-monitoring corpus:
`.context/plans/2026-10-24-fuel-db-baseline-dashboard.md`. The SQL comments cite it as
"design §n". This folder carries no site data.
## Files
| File | Creates | Readable by `fuel_ro` (Superset, Petrolyst server) |
|---|---|---|
| `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 |
| `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.
**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)
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`. `fuel_ro` can then read the `public`
views through the existing default privileges, with no role or grant changes.
2. Open the tunnel and apply the four files as the loader:
```sh
ssh -L 5433:127.0.0.1:5433 petrolyst
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
done
```
3. Check as `fuel_ro`
(`docker exec -it petrolyst-fuel-db-1 psql -U postgres -d fuel`, then `SET ROLE fuel_ro;`):
- `\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 `fuel_ro` never needs,
and never gets, read access to `fm`. PostgreSQL's catalog still shows every schema's table and
column names to any role, so Superset's SQL Lab can list fm's names; names only, no rows.
## Data
- Nobody edits `fm.*` by hand. Rows arrive only through the corpus loader (`fmdb.py export` /
`load`, phase P1, not yet written), in one transaction per load.
- Every value is a claim:
- a correction marks the old row `superseded`, inserts the new row under the same `source_key`,
and points `superseded_by` at it;
- the old row may point at itself until the new row exists;
- a removed record is `retracted`, never deleted.
- Review status, coordinate acceptance and assumption acceptance come only from an `fm.review` row
that matches the exact `assertion_sha256` (rule 11).
- A baseline version is inserted as `proposed`. `approved` is a later update, refused unless the
version meets its policy and its inputs are unchanged (rule 1).
## Trigger-backed rules (in 0001)
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 →
superseded. An input never moves to another version.
2. An internal transfer's tanks are placed on the movement's holder on the visit date.
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.
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
population.
7. A source document supersedes only one of its own family. The same document under a second
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:
- valid when written, and written in the assumption's own transaction;
- immutable afterwards;
- complete at commit: as many counters as covered sets.
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.
11. Status comes only from a matching review. Values are never corrected in place.
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, holder, date and sequence; a
source document's hash, family and kind (its rank); an asset's uid and kind; a place's uid; a
holder's uid, kind and asset; a meter's id and holder; an epoch's id and meter; any row's customer.
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
Before any data matters, run `DROP SCHEMA licence CASCADE; DROP SCHEMA fm CASCADE` as the loader. This
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
it, except baseline versions and their approvals, which live only in fuel-db: export
`fm.baseline_version` and `fm.baseline_input` first.
## Verified locally (2026-10-30, scratch only)
Not run against the live fuel-db. An earlier set of 150 checks was a scratch script, since removed;
these replace it:
- **Rules:** `../tests/run.sh` applies these files to a throw-away database and runs `tests/rules.sql`
and `tests/read_only.sql`, with synthetic data only: each rule with an accepted and a refused case,
two customers, a vehicle as asset and holder, licences, and what `fuel_ro` may read.
- **Loader:** fuel-monitoring's `make selftest` and `bin/fmdb.py dbtest`.
- **Row comparison:** before this rewrite, all 18 views were dumped from a database loaded from the
corpus and a recorded review manifest; after it, the same corpus gave identical rows, leaving out
`operator_uid`. That one-time comparison covers only data the corpus has; rules with no corpus data
rest on `rules.sql`.
@@ -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
USER root
# The 6.1.0 image ships neither an OAuth client nor the Postgres and Trino
# drivers.
# The 6.1.0 image ships neither an OAuth client nor the Postgres driver.
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 roles/ /app/petrolyst-roles/
COPY --from=insights --chown=superset:superset assets/branding/ /app/superset/static/assets/branding/
COPY --from=insights patches/ /app/patches/petrolyst/
# 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
# 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
# and whenever that tag changes. Every step is idempotent. Unlike the
# upstream docker-init.sh it never creates a local admin: users come from
@@ -9,6 +9,8 @@ set -euo pipefail
superset db upgrade
superset init
# No password here: superset_config.py reads it from /run/secrets at connect time.
superset set-database-uri \
--database_name "Fuel (Trino)" \
--uri "trino://superset@trino:8080/fuel"
--database_name "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
command:
- --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-secret-file=/run/secrets/keycloak_petrolyst_client_secret
- --cookie-secret-file=/run/secrets/oauth2_proxy_cookie_secret
@@ -114,6 +114,6 @@ volumes:
secrets:
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:
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/traefik/oauth2_proxy_cookie_secret