Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89b1709fd9 | ||
|
|
d95e6e184a | ||
|
|
78a498f1ce | ||
|
|
ab553526f6 | ||
|
|
e4806f5799 | ||
|
|
8405706ed8 | ||
|
|
686318dfac | ||
|
|
c92c960025 | ||
|
|
da3f803947 | ||
|
|
1b8e57adb4 | ||
|
|
97284052c1 | ||
|
|
bc27bf2356 | ||
|
|
bf96edc828 |
No files matched your search
@@ -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
|
|
||||||
@@ -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,34 +1,37 @@
|
|||||||
# petrolyst-infra
|
# petrolyst-infra
|
||||||
|
|
||||||
Everything that runs on the petrolyst server (109.199.127.84, Ubuntu 24.04), deployed on every
|
Everything that runs on the petrolyst server (109.199.127.84, Ubuntu 24.04):
|
||||||
push to `main`:
|
|
||||||
|
|
||||||
- **Traefik** at the edge for every project on the server. Its dashboard is at
|
- **Traefik** at the edge for every project on the server. Its dashboard is at
|
||||||
https://traefik.petrolyst.com, behind Keycloak.
|
https://traefik.petrolyst.com, behind Keycloak.
|
||||||
- **fuel-monitoring** (ATOMA): Superset at https://atoma.petrolyst.com, reading the operational
|
- **fuel-monitoring** (ATOMA): Superset at https://atoma.petrolyst.com, reading the operational
|
||||||
store `fuel-db` (Postgres 17 with TimescaleDB) through Trino. Sign-in goes through Keycloak
|
store `fuel-db` (Postgres 17 with TimescaleDB) directly, as the read-only role `fuel_ro`
|
||||||
|
(no Trino since 29 Oct 2026). Sign-in goes through Keycloak
|
||||||
client `petrolyst`, and the UI carries the Petrolyst brand.
|
client `petrolyst`, and the UI carries the Petrolyst brand.
|
||||||
|
|
||||||
|
**Deploys are manual.** A person deploys a clean commit of `main` from the Mac ("Manual deploy"
|
||||||
|
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:
|
Customization and deployment are separate, as with media-insights and media-intelligence-docker:
|
||||||
|
|
||||||
| Folder | Holds |
|
| Folder | Holds |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `petrolyst-insights/` | how Superset looks and behaves for Petrolyst: config, Keycloak login code, branding, patches, dashboards. See its README. |
|
| `petrolyst-insights/` | how Superset looks and behaves for Petrolyst: config, Keycloak login code, branding, patches, dashboards. See its README. |
|
||||||
| `traefik/` | edge proxy, TLS, the `sso` middleware (oauth2-proxy) that guards the dashboard |
|
| `traefik/` | edge proxy, TLS, the `sso` middleware (oauth2-proxy) that guards the dashboard |
|
||||||
| `stacks/fuel-monitoring/` | Compose stack, Superset Dockerfile, Trino config and catalog, fuel-db roles |
|
| `stacks/fuel-monitoring/` | Compose stack, Superset Dockerfile and init, fuel-db roles and migrations |
|
||||||
| `deploy/` | `deploy.sh` (run by CI), `rollback.sh` (manual), `lib.sh` (shared) |
|
| `deploy/` | `deploy.sh` (run on the server by a manual deploy), `rollback.sh` (manual), `lib.sh` (shared) |
|
||||||
| `host/` | one-time server setup: `bootstrap.sh`, `secrets-init.sh` |
|
| `host/` | one-time server setup: `bootstrap.sh`, `secrets-init.sh` |
|
||||||
| `keycloak/` | the `petrolyst` client for Keycloak's partial import |
|
| `keycloak/` | the clients `petrolyst` and `petrolyst-app` for Keycloak's partial import |
|
||||||
| `.gitea/workflows/deploy.yml` | the pipeline |
|
|
||||||
|
|
||||||
No credential is in this repository. Secrets exist only on the server, under
|
No credential is in this repository. Secrets exist only on the server, under
|
||||||
`/opt/petrolyst/secrets/`; 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
|
## How a deploy works
|
||||||
|
|
||||||
1. A push to `main` (or a manual run) starts `.gitea/workflows/deploy.yml` on the act_runner. It
|
1. A person runs "Manual deploy" (Operating, below) from a clean checkout of `main`: the scripts
|
||||||
lints the scripts, rsyncs the checkout to a fresh `/opt/petrolyst/incoming/<sha>-<run>/`, and
|
are linted, `git archive` sends exactly that commit to a fresh
|
||||||
calls `deploy/deploy.sh <sha> <that dir>` over SSH as user `deploy`.
|
`/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
|
2. `deploy.sh` takes the host lock `/opt/petrolyst/deploy.lock`, which it shares with
|
||||||
`rollback.sh`, and keeps it until it exits.
|
`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
|
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`:
|
4. It resolves the artifacts and writes them to `releases/<sha>/ARTIFACTS`:
|
||||||
- `SUPERSET_IMAGE_TAG` is a hash of exactly the Superset image's build inputs. They include
|
- `SUPERSET_IMAGE_TAG` is a hash of exactly the Superset image's build inputs. They include
|
||||||
`superset/init.sh`, so changing that file runs `superset-init` again.
|
`superset/init.sh`, so changing that file runs `superset-init` again.
|
||||||
- `TRINO_CONFIG_HASH` and `TRAEFIK_CONFIG_HASH` are hashes of the bind-mounted config.
|
- `TRAEFIK_CONFIG_HASH` is a hash of Traefik's bind-mounted static config.
|
||||||
- Compose recreates a container only when its value changes.
|
- Compose recreates a container only when its value changes.
|
||||||
5. Preflight, before anything live changes:
|
5. Preflight, before anything live changes:
|
||||||
- every secret and env file exists;
|
- every secret file exists;
|
||||||
- `petrolyst-insights/` holds no deployment files;
|
- `petrolyst-insights/` holds no deployment files;
|
||||||
- `docker compose config` passes for every stack;
|
- `docker compose config` passes for every stack;
|
||||||
- the Superset image is built if it is new;
|
- the Superset image is built if it is new;
|
||||||
@@ -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`.
|
- An empty database is a fresh install: it runs `superset-init`.
|
||||||
- If the image changed, or the database is not at the image's migration head: it stops
|
- If the image changed, or the database is not at the image's migration head: it stops
|
||||||
`superset`, writes `backups/superset-meta_<old tag>_<revision>_<time>.dump`, then runs
|
`superset`, writes `backups/superset-meta_<old tag>_<revision>_<time>.dump`, then runs
|
||||||
`superset-init` (`db upgrade`, `init`, registers "Fuel (Trino)").
|
`superset-init` (`db upgrade`, `init`, registers "Fuel": fuel-db as `fuel_ro`, its password
|
||||||
|
read from `/run/secrets` at connect time, never stored in Superset).
|
||||||
- If the image is unchanged and the schema is at its head, it does none of this.
|
- If the image is unchanged and the schema is at its head, it does none of this.
|
||||||
8. Smoke tests. A failure 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.
|
- `/health` answers through Traefik with a valid certificate.
|
||||||
- `/login/` leads to Keycloak with `client_id=petrolyst` and PKCE.
|
- `/login/` leads to Keycloak with `client_id=petrolyst` and PKCE.
|
||||||
- The dashboard redirects to Keycloak.
|
- The dashboard redirects to Keycloak.
|
||||||
- Trino reads `fuel`, and Superset reaches Trino.
|
- Superset's "Fuel" connection reads fuel-db as `fuel_ro`, and its transactions are read-only.
|
||||||
9. Retention:
|
9. Retention:
|
||||||
- 5 releases;
|
- 5 releases;
|
||||||
- backups for 30 days;
|
- backups for 30 days;
|
||||||
- the 3 newest Superset images, plus any image that a kept release or backup names;
|
- the 3 newest Superset images, plus any image that a kept release or backup names;
|
||||||
- uploads older than a day.
|
- uploads older than a day.
|
||||||
|
|
||||||
The runner then checks both hosts from outside.
|
The manual deploy then checks both hosts from outside.
|
||||||
|
|
||||||
## Server paths
|
## Server paths
|
||||||
|
|
||||||
| Path | Owner | What |
|
| Path | Owner | What |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/opt/petrolyst/infra` | deploy | live tree (the promoted release) |
|
| `/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/releases/<sha>` | deploy, read-only | finalized release plus `ARTIFACTS`; the last 5 are kept |
|
||||||
| `/opt/petrolyst/secrets/{shared,traefik,fuel-monitoring}/` | root:deploy 0750, files 0444 | secrets, mounted as `/run/secrets/<name>` |
|
| `/opt/petrolyst/secrets/{shared,traefik,fuel-monitoring}/` | root:deploy 0750, files 0444 | secrets, mounted as `/run/secrets/<name>` |
|
||||||
| `/opt/petrolyst/env/fuel-monitoring.env` | root:deploy 0440 | `FUEL_DB_TRINO_PASSWORD` for Trino's `${ENV:…}` |
|
|
||||||
| `/opt/petrolyst/backups/` | deploy 0750 | Superset metadata dumps |
|
| `/opt/petrolyst/backups/` | deploy 0750 | Superset metadata dumps |
|
||||||
| `/opt/petrolyst/state/` | deploy | last deployed SHA and Superset image tag; `rollback-in-progress` during a migration rollback |
|
| `/opt/petrolyst/state/` | deploy | last deployed SHA and Superset image tag; `rollback-in-progress` during a migration rollback |
|
||||||
|
|
||||||
@@ -81,34 +84,27 @@ The runner then checks both hosts from outside.
|
|||||||
|
|
||||||
### 1. Gitea
|
### 1. Gitea
|
||||||
|
|
||||||
1. Create the private repository `Petrolyst/petrolyst-infra` and push this repository's
|
Create the private repository `Petrolyst/petrolyst-infra` and push this repository's `main` to
|
||||||
`main` to it. Enable Actions under Settings → Repository → Units.
|
it. Gitea only keeps the history: deploys are manual, so it needs no runner, no Actions and no
|
||||||
2. Runner. Check Site/Org Administration → Actions → Runners. If none is registered, run
|
deploy secrets.
|
||||||
[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.
|
|
||||||
|
|
||||||
### 2. Keycloak (realm `thedigitalbridge` on iam.thedigitalbridge.io)
|
### 2. Keycloak (realm `petrolyst` on iam.thedigitalbridge.io)
|
||||||
|
|
||||||
1. Realm settings → Action → **Partial import**: choose `keycloak/petrolyst-partial-import.json`,
|
1. Realm settings → Action → **Partial import**: choose `keycloak/petrolyst-realm-import.json`,
|
||||||
and "Skip" if a resource exists. This creates:
|
and "Skip" if a resource exists. This creates:
|
||||||
- the confidential client `petrolyst`: standard flow only, PKCE S256, redirect URIs for both
|
- the confidential client `petrolyst`, for Superset and Traefik: standard flow only, PKCE S256,
|
||||||
hosts;
|
redirect URIs for both hosts;
|
||||||
- its client roles `superset-admin`, `superset-editor`, `superset-viewer` and `traefik-admin`;
|
- its client roles `superset-admin`, `superset-editor`, `superset-viewer` and `traefik-admin`;
|
||||||
- a mapper that puts those roles into the claim `petrolyst_roles`.
|
- a mapper that puts those roles into the claim `petrolyst_roles`;
|
||||||
|
- the confidential client `petrolyst-app`, for the Petrolyst app, with its role `reader` and two
|
||||||
|
mappers: `petrolyst_roles`, and the person's groups with their full path in `petrolyst_groups`,
|
||||||
|
both in the ID token. Both clients' addresses are on app.petrolyst.com, where everything moves
|
||||||
|
(PD-16, PD-21); atoma.petrolyst.com is going away and is not in the import.
|
||||||
2. Clients → petrolyst → Credentials: copy the client secret (step 3 stores it on the server).
|
2. Clients → petrolyst → Credentials: copy the client secret (step 3 stores it on the server).
|
||||||
|
**Realm settings:** registration off; brute-force detection on; a password policy of at least 12
|
||||||
|
characters, because a password is the only lock for customers' users; SSO session idle 8 hours,
|
||||||
|
max 10 hours, because the app's session is renewed through Keycloak about hourly and the default
|
||||||
|
idle limit of 30 minutes would ask for a password each time.
|
||||||
3. **MFA**, which Exhibit B requires. A copy of the built-in `browser` flow is not enough, for two
|
3. **MFA**, which Exhibit B requires. A copy of the built-in `browser` flow is not enough, for two
|
||||||
reasons: its Cookie step accepts an existing password-only session from another client of the
|
reasons: its Cookie step accepts an existing password-only session from another client of the
|
||||||
realm, and its conditional OTP subflow skips users who have not set up OTP. Instead:
|
realm, and its conditional OTP subflow skips users who have not set up OTP. Instead:
|
||||||
@@ -118,38 +114,50 @@ The runner then checks both hosts from outside.
|
|||||||
- Clients → petrolyst → Advanced → Authentication flow overrides → Browser flow:
|
- Clients → petrolyst → Advanced → Authentication flow overrides → Browser flow:
|
||||||
`petrolyst-mfa`.
|
`petrolyst-mfa`.
|
||||||
|
|
||||||
Result: every sign-in to a Petrolyst app asks for password and OTP, and a user without OTP
|
Result: every sign-in to Superset or Traefik asks for password and OTP, and a user without OTP
|
||||||
enrols first. Other clients of the realm are unaffected. There is no silent SSO between
|
enrols first. There is no silent SSO between them. Client `petrolyst-app` keeps the realm's own
|
||||||
Superset and the dashboard.
|
flow: customers' users sign in with a password only, the owner's choice "for now" (PD-21).
|
||||||
4. Give people roles: Users → (user) → Role mapping → Assign role → filter by clients →
|
4. **A customer is a top-level group.** Groups → Create group, named as the customer's
|
||||||
`petrolyst`. Group mappings work too.
|
`keycloak_group` in fuel-db (without the slash), then Role mapping → Assign role → filter by
|
||||||
|
clients → `petrolyst-app` `reader`. Adding a person to the group is then the only step, and they
|
||||||
|
see that customer in the app and no other. Exactly one customer per person: two give "No access".
|
||||||
|
They need no role on client `petrolyst`, and Superset refuses them. A person in no customer's
|
||||||
|
group sees "No access"; that includes our own staff, who join a customer's group to see it in
|
||||||
|
the app.
|
||||||
|
**Administrators:** Users → (user) → Role mapping → Assign role → filter by clients → `petrolyst`.
|
||||||
5. Users of the Traefik dashboard need **Email verified** switched on, because oauth2-proxy
|
5. Users of the Traefik dashboard need **Email verified** switched on, because oauth2-proxy
|
||||||
refuses unverified addresses.
|
refuses unverified addresses.
|
||||||
|
|
||||||
|
6. **Branding (optional):** the sign-in and sign-out pages, theme `petrolyst` in `keycloak/themes/`, for the whole realm, both clients.
|
||||||
|
- Build on the Mac: `keycloak/themes/build.sh <folder with Satoshi-Variable.woff2>`, normally `petrolyst-platform/web/src/fonts/satoshi`.
|
||||||
|
The font goes only into `dist/petrolyst-theme.jar`, never into Git, because its licence forbids distributing it: so build it yourself and do not hand the file to outsiders.
|
||||||
|
- Install on Keycloak's server, either way. **No restart:** put the jar's `theme/petrolyst/` folder into Keycloak's `themes/` folder, readable by Keycloak's
|
||||||
|
user. Keycloak listed and used it without a restart on a test 26.4 with theme caching on; not tried on the real one. **With a restart,** which drops sign-in for every
|
||||||
|
realm for a moment: the jar into `providers/`, and `kc.sh build` if it runs an optimised build.
|
||||||
|
- Realm `petrolyst` → Realm settings → Themes → Login theme → `petrolyst` → Save. Other realms keep their look. To undo, set it back.
|
||||||
|
- `keycloak/themes/check-brand.sh` says whether the theme's copies of the brand tokens still match `Petrolyst/design/`.
|
||||||
|
|
||||||
### 3. Server (as root, `ssh petrolyst`)
|
### 3. Server (as root, `ssh petrolyst`)
|
||||||
|
|
||||||
The server needs only the two host scripts; it never gets Gitea credentials.
|
The server needs only the two host scripts; it never gets Gitea credentials.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# on the Mac, in this repository
|
# 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
|
ssh petrolyst
|
||||||
# on the server, as root
|
# 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 # generates every secret; reports the missing Keycloak one
|
||||||
/root/secrets-init.sh keycloak-secret # paste the secret from Keycloak step 2
|
/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:
|
`bootstrap.sh` does the following:
|
||||||
- installs Docker from Docker's apt repository;
|
- installs Docker from Docker's apt repository;
|
||||||
- adds 4 GB swap;
|
- adds 4 GB swap;
|
||||||
- turns on ufw for 22, 80 and 443 only;
|
- turns on ufw for 22, 80 and 443 only;
|
||||||
- turns off SSH password login and root password login;
|
- 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`.
|
- creates `/opt/petrolyst` and the Docker network `proxy`.
|
||||||
|
|
||||||
**Before closing your root session**, check from a second terminal that `ssh petrolyst` still
|
**Before closing your root session**, check from a second terminal that `ssh petrolyst` still
|
||||||
@@ -157,8 +165,7 @@ works.
|
|||||||
|
|
||||||
### 4. First deploy
|
### 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
|
path. Let's Encrypt needs about a minute
|
||||||
for the two certificates; the smoke tests wait up to two minutes.
|
for the two certificates; the smoke tests wait up to two minutes.
|
||||||
|
|
||||||
@@ -169,17 +176,29 @@ becomes the first Superset admin; there is no local admin account.
|
|||||||
|
|
||||||
| Task | How |
|
| Task | How |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Deploy | push to `main` (without a runner: "Manual deploy" below) |
|
| Deploy | "Manual deploy" below, from a clean `main` |
|
||||||
| Re-deploy a kept release | `sudo -u deploy /opt/petrolyst/infra/deploy/deploy.sh <sha>` |
|
| Re-deploy a kept release | `sudo -u deploy /opt/petrolyst/infra/deploy/deploy.sh <sha>`. Not across a change of Keycloak realm: see "Moving to realm petrolyst" |
|
||||||
| Roll back | see below |
|
| Roll back | see below |
|
||||||
| Traefik dashboard | https://traefik.petrolyst.com (role `traefik-admin`) |
|
| Traefik dashboard | https://traefik.petrolyst.com (role `traefik-admin`) |
|
||||||
| Trino shell | `docker exec -it fuel-monitoring-trino-1 trino` |
|
| fuel-db shell | `docker exec -it petrolyst-fuel-db-1 psql -U postgres -d fuel` |
|
||||||
| fuel-db from the Mac | `ssh -L 5433:127.0.0.1:5433 petrolyst`, then connect to `localhost:5433`, database `fuel` |
|
| fuel-db from the Mac | `ssh -L 5433:127.0.0.1:5433 petrolyst`, then connect to `localhost:5433`, database `fuel` |
|
||||||
| Logs | `docker compose -p fuel-monitoring logs -f superset` (or `-p traefik`) |
|
| Logs | `docker compose -p petrolyst logs -f superset` (or `-p traefik`) |
|
||||||
| Superset break-glass | `docker exec -it fuel-monitoring-superset-1 superset fab ...` |
|
| Superset break-glass | `docker exec -it petrolyst-superset-1 superset fab ...` |
|
||||||
|
|
||||||
**Manual deploy** (no runner): commit first, so that `git status` is clean. Then run this on the
|
**Manual deploy**, the only way to deploy (there is no CI runner). On the Mac, in this
|
||||||
Mac, in this repository:
|
repository:
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```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
|
```bash
|
||||||
sha=$(git rev-parse HEAD)
|
sha=$(git rev-parse HEAD)
|
||||||
@@ -189,10 +208,18 @@ git archive --format=tar "$sha" |
|
|||||||
ssh petrolyst "sudo -u deploy $dir/deploy/deploy.sh $sha $dir"
|
ssh petrolyst "sudo -u deploy $dir/deploy/deploy.sh $sha $dir"
|
||||||
```
|
```
|
||||||
|
|
||||||
`git archive` sends exactly the commit, never uncommitted or ignored files such as the deploy
|
4. Check both hosts from outside:
|
||||||
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
|
```bash
|
||||||
register one.
|
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):
|
**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
|
Afterwards, `git revert` the bad commit and push, so `main` matches. A plain revert without the
|
||||||
rollback script is stopped by the downgrade guard.
|
rollback script is stopped by the downgrade guard.
|
||||||
|
|
||||||
**Data loaders** (fuel-db). Every table in `public` must belong to `fuel_owner`. `trino_ro` can
|
**Data loaders** (fuel-db). Every table in `public` must belong to `fuel_owner`. `fuel_ro` can
|
||||||
read new tables only through `fuel_owner`'s default privileges, and PostgreSQL applies those
|
read new tables only through `fuel_owner`'s default privileges, and PostgreSQL applies those
|
||||||
only to objects `fuel_owner` creates itself. A table that a loader creates as its own role stays
|
only to objects `fuel_owner` creates itself. A table that a loader creates as its own role stays
|
||||||
unreadable for Trino. Create each loader role so that its sessions act as `fuel_owner`, and it
|
unreadable for Superset and the Petrolyst server. Create each loader role so that its sessions act as `fuel_owner`, and it
|
||||||
cannot create anything as itself. Run this in
|
cannot create anything as itself. Run this in
|
||||||
`docker exec -it fuel-monitoring-fuel-db-1 psql -U postgres -d fuel`:
|
`docker exec -it petrolyst-fuel-db-1 psql -U postgres -d fuel`:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE ROLE <loader> LOGIN PASSWORD '<password>';
|
CREATE ROLE <loader> LOGIN PASSWORD '<password>';
|
||||||
@@ -233,9 +260,74 @@ GRANT fuel_owner TO <loader> WITH INHERIT FALSE, SET TRUE;
|
|||||||
ALTER ROLE <loader> SET role TO 'fuel_owner';
|
ALTER ROLE <loader> SET role TO 'fuel_owner';
|
||||||
```
|
```
|
||||||
|
|
||||||
Tables it creates, hypertables included, then belong to `fuel_owner`, and Trino can read them.
|
Tables it creates, hypertables included, then belong to `fuel_owner`, and `fuel_ro` can read them.
|
||||||
After `SET ROLE NONE`, it cannot create anything in `public`. To repair a table that another
|
After `SET ROLE NONE`, it cannot create anything in `public`. To repair a table that another
|
||||||
role created, run `ALTER TABLE <t> OWNER TO fuel_owner`, then `GRANT SELECT ON <t> TO trino_ro`.
|
role created, run `ALTER TABLE <t> OWNER TO fuel_owner`, then `GRANT SELECT ON <t> TO fuel_ro`.
|
||||||
|
|
||||||
|
**Removing the old project**, once, right before the next deploy; delete this paragraph when it
|
||||||
|
is done. The server still runs the Compose project `fuel-monitoring`, with Trino. Afterwards it runs
|
||||||
|
`petrolyst`, without it: containers are `petrolyst-<service>-1`, and the data volumes keep their
|
||||||
|
names (pinned in `compose.yaml`), so no data moves. The old containers must be gone first: two
|
||||||
|
PostgreSQL servers on one data volume corrupt it, and preflight refuses to deploy while any exists.
|
||||||
|
|
||||||
|
As root on the server (Superset is down from step 3 until the deploy finishes). The role keeps its
|
||||||
|
password, because a SCRAM password survives a rename:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
S=/opt/petrolyst/secrets/fuel-monitoring
|
||||||
|
mv "$S/fuel_db_trino_password" "$S/fuel_db_ro_password" # 1
|
||||||
|
docker exec -i fuel-monitoring-fuel-db-1 psql -U postgres -d fuel -v ON_ERROR_STOP=1 \
|
||||||
|
-c 'ALTER ROLE trino_ro RENAME TO fuel_ro' \
|
||||||
|
-c 'ALTER ROLE fuel_ro SET default_transaction_read_only = on' # 2
|
||||||
|
docker rm -f $(docker ps -aq --filter label=com.docker.compose.project=fuel-monitoring) # 3
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Deploy as usual ("Manual deploy"). Compose warns once that the volumes belong to another project;
|
||||||
|
that is expected. `superset-init` registers the connection "Fuel"; the smoke test checks that it
|
||||||
|
reads as `fuel_ro`, read-only.
|
||||||
|
5. Delete the old connection. It refuses if a dataset still uses it; then stop and look:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -i petrolyst-superset-1 /app/.venv/bin/python - <<'PY'
|
||||||
|
from superset.app import create_app
|
||||||
|
|
||||||
|
with create_app().app_context():
|
||||||
|
from superset import db
|
||||||
|
from superset.connectors.sqla.models import SqlaTable
|
||||||
|
from superset.models.core import Database
|
||||||
|
|
||||||
|
old = db.session.query(Database).filter_by(database_name="Fuel (Trino)").one_or_none()
|
||||||
|
if old:
|
||||||
|
used = db.session.query(SqlaTable).filter_by(database_id=old.id).count()
|
||||||
|
if used:
|
||||||
|
raise SystemExit(f"{used} datasets still use the old connection")
|
||||||
|
db.session.delete(old)
|
||||||
|
db.session.commit()
|
||||||
|
print("old connection deleted")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
6. Clean up: `rm /opt/petrolyst/env/fuel-monitoring.env`, `rmdir /opt/petrolyst/env`,
|
||||||
|
`docker image rm trinodb/trino:484`,
|
||||||
|
`docker network rm fuel-monitoring_backend fuel-monitoring_fuel-db-tunnel`.
|
||||||
|
7. Releases from before this deploy can no longer run, as their role is gone, and `rollback.sh`
|
||||||
|
stops on them. Remove them from `/opt/petrolyst/releases/`.
|
||||||
|
|
||||||
|
**Moving to realm `petrolyst`, one time.** A deploy of its own, after the deploy that removes Trino:
|
||||||
|
releases from before that one can no longer run, so a combined deploy leaves nothing to roll back to.
|
||||||
|
1. The realm is ready (Keycloak, above) and an admin has signed in on client `petrolyst` there. While
|
||||||
|
Superset still answers at atoma.petrolyst.com, add that address's redirect URI to the client by
|
||||||
|
hand, or nobody can sign in after step 3; remove it when the domain goes.
|
||||||
|
2. As root: `host/secrets-init.sh keycloak-secret`. The value goes into the new file
|
||||||
|
`shared/keycloak_petrolyst_realm_client_secret`, so nothing changes for anyone yet. Leave the old
|
||||||
|
file `shared/keycloak_petrolyst_client_secret` alone.
|
||||||
|
3. Deploy by "Manual deploy" only. That one step moves Superset and Traefik together.
|
||||||
|
4. **Prove it by hand:** an admin signs in to Superset and to the Traefik dashboard. The smoke tests
|
||||||
|
check redirects only, and pass with a wrong secret.
|
||||||
|
5. **To go back:** `rollback.sh <previous sha>` returns to the old realm with the old secret. Afterwards go
|
||||||
|
forward again by "Manual deploy" only: "Re-deploy a kept release" runs the old `lib.sh`, whose realm
|
||||||
|
address would override the new release's.
|
||||||
|
6. Not before 7 days of working sign-ins, and on the owner's word: retire client `petrolyst` in realm
|
||||||
|
`thedigitalbridge` and delete the old secret file. From then on there is no way back.
|
||||||
|
|
||||||
**Adding a project**: create `stacks/<name>/compose.yaml`, attach the public service to the
|
**Adding a project**: create `stacks/<name>/compose.yaml`, attach the public service to the
|
||||||
external network `proxy`, and set `traefik.*` labels with a unique router name and
|
external network `proxy`, and set `traefik.*` labels with a unique router name and
|
||||||
@@ -263,7 +355,6 @@ Docker Desktop, with a scratch `/opt/petrolyst`. Production differences in that
|
|||||||
Results:
|
Results:
|
||||||
- The fresh install took the init path.
|
- The fresh install took the init path.
|
||||||
- An identical re-run recreated nothing and left `ARTIFACTS` byte-identical.
|
- An identical re-run recreated nothing and left `ARTIFACTS` byte-identical.
|
||||||
- A Trino-only change recreated only `trino`.
|
|
||||||
- A Superset change stopped, backed up, migrated and recreated only `superset`.
|
- A Superset change stopped, backed up, migrated and recreated only `superset`.
|
||||||
- A change to `superset/init.sh` alone did the same, and the changed script ran.
|
- A change to `superset/init.sh` alone did the same, and the changed script ran.
|
||||||
- A same-SHA upload with different content was refused.
|
- A same-SHA upload with different content was refused.
|
||||||
@@ -280,19 +371,17 @@ Results:
|
|||||||
- Retention pruned read-only releases.
|
- Retention pruned read-only releases.
|
||||||
- Retention removed a queued upload that a plain `rsync -a` had given an old checkout's
|
- Retention removed a queued upload that a plain `rsync -a` had given an old checkout's
|
||||||
directory times. It kept one uploaded with `--omit-dir-times`.
|
directory times. It kept one uploaded with `--omit-dir-times`.
|
||||||
- In `fuel-db`, a loader that created a table as its own role left it unreadable for `trino_ro`.
|
- In `fuel-db`, a loader that created a table as its own role left it unreadable for the read-only
|
||||||
With the "Data loaders" recipe, tables (hypertables too) belonged to `fuel_owner` and Trino
|
role. With the "Data loaders" recipe, tables (hypertables too) belonged to `fuel_owner` and that
|
||||||
could read them. After `SET ROLE NONE`, the loader could not create anything.
|
role could read them. After `SET ROLE NONE`, the loader could not create anything.
|
||||||
- Smoke tests passed, including the redirects to the real Keycloak with `client_id=petrolyst`
|
- Smoke tests passed, including the redirects to the real Keycloak with `client_id=petrolyst`
|
||||||
and PKCE S256.
|
and PKCE S256.
|
||||||
- TLS 1.1 and unknown SNI were refused, and HTTP redirected to HTTPS.
|
- TLS 1.1 and unknown SNI were refused, and HTTP redirected to HTTPS.
|
||||||
- Trino and `trino_ro` both refused writes.
|
|
||||||
|
|
||||||
Not verified locally:
|
Not verified locally:
|
||||||
- Let's Encrypt issuance;
|
- Let's Encrypt issuance;
|
||||||
- a real Keycloak sign-in, OTP and logout (they need the client from setup step 2);
|
- a real Keycloak sign-in, OTP and logout (they need the client from setup step 2);
|
||||||
- the theme as rendered in a browser;
|
- the theme as rendered in a browser.
|
||||||
- the Gitea runner.
|
|
||||||
|
|
||||||
## Open items
|
## Open items
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Server-side deploy, run as the non-root deploy user:
|
# 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
|
# deploy.sh <sha> re-deploy a release that is already finalized
|
||||||
#
|
#
|
||||||
# Order: lock, finalize, artifacts, preflight, image build and downgrade guard
|
# Order: lock, finalize, artifacts, preflight, image build and downgrade guard
|
||||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
|||||||
# shellcheck source=deploy/lib.sh
|
# shellcheck source=deploy/lib.sh
|
||||||
. "$(dirname "$(readlink -f "$0")")/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
|
# moved in atomically; a re-run of a deployed SHA must match it byte for byte
|
||||||
# and leaves the finalized release, ARTIFACTS included, untouched.
|
# and leaves the finalized release, ARTIFACTS included, untouched.
|
||||||
finalize_release() {
|
finalize_release() {
|
||||||
@@ -44,7 +44,7 @@ resolve_artifacts() {
|
|||||||
fi
|
fi
|
||||||
chmod -R a-w "$release"
|
chmod -R a-w "$release"
|
||||||
load_artifacts "$release"
|
load_artifacts "$release"
|
||||||
log "artifacts: superset $SUPERSET_IMAGE_TAG, trino config $TRINO_CONFIG_HASH, traefik config $TRAEFIK_CONFIG_HASH"
|
log "artifacts: superset $SUPERSET_IMAGE_TAG, traefik config $TRAEFIK_CONFIG_HASH"
|
||||||
}
|
}
|
||||||
|
|
||||||
# prepare_fuel_monitoring <release>: before anything live changes, build the
|
# prepare_fuel_monitoring <release>: before anything live changes, build the
|
||||||
@@ -100,10 +100,10 @@ retention() {
|
|||||||
rm -rf "$dir"
|
rm -rf "$dir"
|
||||||
log "removed release ${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-)
|
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
|
# Uploads older than a day were never finalized (interrupted deploys). The
|
||||||
# mtime is the server's (the workflow's rsync omits directory times), and a
|
# upload directory is created on the server when the upload starts, and its
|
||||||
# CI job lives at most 45 minutes, so an upload being sent or waiting for the
|
# mtime is the server's, so an upload being sent or waiting for the lock is
|
||||||
# lock is never this old.
|
# never this old.
|
||||||
while IFS= read -r -d '' dir; do
|
while IFS= read -r -d '' dir; do
|
||||||
chmod -R u+w "$dir"
|
chmod -R u+w "$dir"
|
||||||
rm -rf "$dir"
|
rm -rf "$dir"
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ ROLLBACK_MARK=$STATE/rollback-in-progress
|
|||||||
|
|
||||||
ATOMA_HOST=${ATOMA_HOST:-atoma.petrolyst.com}
|
ATOMA_HOST=${ATOMA_HOST:-atoma.petrolyst.com}
|
||||||
TRAEFIK_HOST=${TRAEFIK_HOST:-traefik.petrolyst.com}
|
TRAEFIK_HOST=${TRAEFIK_HOST:-traefik.petrolyst.com}
|
||||||
KEYCLOAK_REALM_URL=${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/thedigitalbridge}
|
|
||||||
PETROLYST_ROOT=$ROOT
|
PETROLYST_ROOT=$ROOT
|
||||||
export ATOMA_HOST TRAEFIK_HOST KEYCLOAK_REALM_URL PETROLYST_ROOT
|
# The Keycloak realm is not set here: each release's compose files name their own, with the secret
|
||||||
|
# file that belongs to it. A rollback, which runs this file from the release it leaves, therefore
|
||||||
|
# returns to the old release's realm and secret. Exporting a value here would override them.
|
||||||
|
export ATOMA_HOST TRAEFIK_HOST PETROLYST_ROOT
|
||||||
|
|
||||||
# --host pins the daemon, as in petrolyst-ui/deploy/deploy.sh: a stale
|
# --host pins the daemon, as in petrolyst-web/deploy/deploy.sh: a stale
|
||||||
# `docker context use` must not send this deploy to another daemon.
|
# `docker context use` must not send this deploy to another daemon.
|
||||||
DOCKER=(docker --host unix:///var/run/docker.sock)
|
DOCKER=(docker --host unix:///var/run/docker.sock)
|
||||||
|
|
||||||
@@ -33,19 +35,19 @@ SUPERSET_INPUTS=(
|
|||||||
stacks/fuel-monitoring/superset/init.sh
|
stacks/fuel-monitoring/superset/init.sh
|
||||||
petrolyst-insights/superset_config.py
|
petrolyst-insights/superset_config.py
|
||||||
petrolyst-insights/petrolyst_security.py
|
petrolyst-insights/petrolyst_security.py
|
||||||
|
petrolyst-insights/roles
|
||||||
petrolyst-insights/assets/branding
|
petrolyst-insights/assets/branding
|
||||||
petrolyst-insights/patches
|
petrolyst-insights/patches
|
||||||
)
|
)
|
||||||
|
|
||||||
# Every file the stacks need from /opt/petrolyst (host/secrets-init.sh).
|
# Every file the stacks need from /opt/petrolyst (host/secrets-init.sh).
|
||||||
|
# The Keycloak client secret is not listed: it depends on the release (check_keycloak_secrets).
|
||||||
REQUIRED_FILES=(
|
REQUIRED_FILES=(
|
||||||
secrets/shared/keycloak_petrolyst_client_secret
|
|
||||||
secrets/traefik/oauth2_proxy_cookie_secret
|
secrets/traefik/oauth2_proxy_cookie_secret
|
||||||
secrets/fuel-monitoring/superset_secret_key
|
secrets/fuel-monitoring/superset_secret_key
|
||||||
secrets/fuel-monitoring/superset_db_password
|
secrets/fuel-monitoring/superset_db_password
|
||||||
secrets/fuel-monitoring/fuel_db_password
|
secrets/fuel-monitoring/fuel_db_password
|
||||||
secrets/fuel-monitoring/fuel_db_trino_password
|
secrets/fuel-monitoring/fuel_db_ro_password
|
||||||
env/fuel-monitoring.env
|
|
||||||
)
|
)
|
||||||
|
|
||||||
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
|
log() { printf '%s %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; }
|
||||||
@@ -57,8 +59,8 @@ die() { log "ERROR: $*"; exit 1; }
|
|||||||
# is absolute, so nothing needs the caller's directory.
|
# is absolute, so nothing needs the caller's directory.
|
||||||
cd /
|
cd /
|
||||||
|
|
||||||
# acquire_lock <seconds>: deploy.sh and rollback.sh share this lock, so a CI
|
# acquire_lock <seconds>: deploy.sh and rollback.sh share this lock, so a
|
||||||
# deploy and a manual rollback never run at the same time. Held until exit.
|
# deploy and a rollback never run at the same time. Held until exit.
|
||||||
acquire_lock() {
|
acquire_lock() {
|
||||||
exec 9>>"$LOCK_FILE"
|
exec 9>>"$LOCK_FILE"
|
||||||
flock -w "$1" 9 || die "another deploy or rollback holds $LOCK_FILE (waited $1 s)"
|
flock -w "$1" 9 || die "another deploy or rollback holds $LOCK_FILE (waited $1 s)"
|
||||||
@@ -82,7 +84,6 @@ hash_inputs() {
|
|||||||
|
|
||||||
compute_artifacts() {
|
compute_artifacts() {
|
||||||
printf 'SUPERSET_IMAGE_TAG=%s\n' "$(hash_inputs "$1" "${SUPERSET_INPUTS[@]}")"
|
printf 'SUPERSET_IMAGE_TAG=%s\n' "$(hash_inputs "$1" "${SUPERSET_INPUTS[@]}")"
|
||||||
printf 'TRINO_CONFIG_HASH=%s\n' "$(hash_inputs "$1" stacks/fuel-monitoring/trino/etc)"
|
|
||||||
# Only the static config: dynamic/ is watched and applies without a restart.
|
# Only the static config: dynamic/ is watched and applies without a restart.
|
||||||
printf 'TRAEFIK_CONFIG_HASH=%s\n' "$(hash_inputs "$1" traefik/static)"
|
printf 'TRAEFIK_CONFIG_HASH=%s\n' "$(hash_inputs "$1" traefik/static)"
|
||||||
}
|
}
|
||||||
@@ -91,16 +92,16 @@ compute_artifacts() {
|
|||||||
load_artifacts() {
|
load_artifacts() {
|
||||||
local file=$1/ARTIFACTS line key value
|
local file=$1/ARTIFACTS line key value
|
||||||
[ -s "$file" ] || die "$file is missing"
|
[ -s "$file" ] || die "$file is missing"
|
||||||
unset SUPERSET_IMAGE_TAG TRINO_CONFIG_HASH TRAEFIK_CONFIG_HASH
|
unset SUPERSET_IMAGE_TAG TRAEFIK_CONFIG_HASH
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
key=${line%%=*}
|
key=${line%%=*}
|
||||||
value=${line#*=}
|
value=${line#*=}
|
||||||
[[ $key =~ ^(SUPERSET_IMAGE_TAG|TRINO_CONFIG_HASH|TRAEFIK_CONFIG_HASH)$ && $value =~ ^[0-9a-f]{16}$ ]] ||
|
[[ $key =~ ^(SUPERSET_IMAGE_TAG|TRAEFIK_CONFIG_HASH)$ && $value =~ ^[0-9a-f]{16}$ ]] ||
|
||||||
die "unexpected line in $file: $line"
|
die "unexpected line in $file: $line"
|
||||||
printf -v "$key" '%s' "$value"
|
printf -v "$key" '%s' "$value"
|
||||||
export "${key?}"
|
export "${key?}"
|
||||||
done <"$file"
|
done <"$file"
|
||||||
[ -n "${SUPERSET_IMAGE_TAG:-}" ] && [ -n "${TRINO_CONFIG_HASH:-}" ] && [ -n "${TRAEFIK_CONFIG_HASH:-}" ] ||
|
[ -n "${SUPERSET_IMAGE_TAG:-}" ] && [ -n "${TRAEFIK_CONFIG_HASH:-}" ] ||
|
||||||
die "$file is incomplete"
|
die "$file is incomplete"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,15 +128,31 @@ preflight() {
|
|||||||
for f in "${REQUIRED_FILES[@]}"; do
|
for f in "${REQUIRED_FILES[@]}"; do
|
||||||
[ -s "$ROOT/$f" ] || die "missing or empty: $ROOT/$f (run host/secrets-init.sh as root)"
|
[ -s "$ROOT/$f" ] || die "missing or empty: $ROOT/$f (run host/secrets-init.sh as root)"
|
||||||
done
|
done
|
||||||
# oauth2-proxy uses the client secret file byte for byte.
|
check_keycloak_secrets "$release"
|
||||||
f=$ROOT/secrets/shared/keycloak_petrolyst_client_secret
|
|
||||||
[ "$(tail -c1 "$f" | wc -l)" -eq 0 ] ||
|
|
||||||
die "$f ends with a newline; store it again with: host/secrets-init.sh keycloak-secret"
|
|
||||||
bad=$(find "$release/petrolyst-insights" \( -name 'Dockerfile*' -o -name '*compose*.y*ml' \
|
bad=$(find "$release/petrolyst-insights" \( -name 'Dockerfile*' -o -name '*compose*.y*ml' \
|
||||||
-o -name '*.env' -o -name '.env' -o -path '*/.gitea/*' -o -path '*/.github/*' \) -print)
|
-o -name '*.env' -o -name '.env' -o -path '*/.gitea/*' -o -path '*/.github/*' \) -print)
|
||||||
[ -z "$bad" ] || die "petrolyst-insights/ holds customization only; found: $bad"
|
[ -z "$bad" ] || die "petrolyst-insights/ holds customization only; found: $bad"
|
||||||
compose "$release" traefik config -q
|
compose "$release" traefik config -q
|
||||||
while IFS= read -r dir; do compose "$release" "$dir" config -q; done < <(stack_dirs "$release")
|
while IFS= read -r dir; do compose "$release" "$dir" config -q; done < <(stack_dirs "$release")
|
||||||
|
# The stack was the Compose project "fuel-monitoring" until 29 Oct 2026; "petrolyst" uses the
|
||||||
|
# same data volumes, so both must never run at once (README "One-time steps after 29 Oct 2026").
|
||||||
|
[ -z "$("${DOCKER[@]}" ps -aq --filter label=com.docker.compose.project=fuel-monitoring)" ] ||
|
||||||
|
die "containers of the old project fuel-monitoring exist; remove them first (README \"Removing the old project\")"
|
||||||
|
}
|
||||||
|
|
||||||
|
# check_keycloak_secrets <release>: the client secret that release's compose files name, which
|
||||||
|
# differs per realm. So a rollback needs only its target's secret, not the one of the release it leaves.
|
||||||
|
check_keycloak_secrets() {
|
||||||
|
local release=$1 names name f
|
||||||
|
names=$(cat "$release/stacks/fuel-monitoring/compose.yaml" "$release/traefik/compose.yaml" |
|
||||||
|
grep -oE 'secrets/shared/keycloak_[a-z_]+' | sort -u) || die "$release names no Keycloak client secret"
|
||||||
|
for name in $names; do
|
||||||
|
f=$ROOT/$name
|
||||||
|
[ -s "$f" ] || die "missing or empty: $f (run host/secrets-init.sh as root)"
|
||||||
|
# oauth2-proxy uses the client secret file byte for byte.
|
||||||
|
[ "$(tail -c1 "$f" | wc -l)" -eq 0 ] ||
|
||||||
|
die "$f ends with a newline; store it again"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# promote <release> <sha>: make the release the live tree. --chmod applies to
|
# promote <release> <sha>: make the release the live tree. --chmod applies to
|
||||||
@@ -231,7 +248,7 @@ probe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
smoke_tests() {
|
smoke_tests() {
|
||||||
local i body code location
|
local i body code location realm
|
||||||
log "smoke tests"
|
log "smoke tests"
|
||||||
# First deploy: Let's Encrypt may need a minute to issue the certificate.
|
# First deploy: Let's Encrypt may need a minute to issue the certificate.
|
||||||
for i in $(seq 1 24); do
|
for i in $(seq 1 24); do
|
||||||
@@ -241,23 +258,40 @@ smoke_tests() {
|
|||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# The realm this release runs on. These tests check redirects only, not a real sign-in: they
|
||||||
|
# pass with a wrong client secret, so a person signs in after a change of realm or secret.
|
||||||
|
realm=$(fm exec -T superset printenv KEYCLOAK_REALM_URL) || die "Superset has no KEYCLOAK_REALM_URL"
|
||||||
|
|
||||||
read -r code location < <(probe "$ATOMA_HOST" /login/)
|
read -r code location < <(probe "$ATOMA_HOST" /login/)
|
||||||
[ "$code" = 302 ] && [[ $location == "https://$ATOMA_HOST/login/keycloak"* ]] ||
|
[ "$code" = 302 ] && [[ $location == "https://$ATOMA_HOST/login/keycloak"* ]] ||
|
||||||
die "/login/ should redirect to /login/keycloak, got $code $location"
|
die "/login/ should redirect to /login/keycloak, got $code $location"
|
||||||
|
|
||||||
read -r code location < <(probe "$ATOMA_HOST" /login/keycloak)
|
read -r code location < <(probe "$ATOMA_HOST" /login/keycloak)
|
||||||
[ "$code" = 302 ] && [[ $location == "$KEYCLOAK_REALM_URL/protocol/openid-connect/auth?"* ]] &&
|
[ "$code" = 302 ] && [[ $location == "$realm/protocol/openid-connect/auth?"* ]] &&
|
||||||
[[ $location == *client_id=petrolyst* && $location == *code_challenge_method=S256* ]] ||
|
[[ $location == *client_id=petrolyst* && $location == *code_challenge_method=S256* ]] ||
|
||||||
die "/login/keycloak should redirect to Keycloak with client_id=petrolyst and PKCE S256, got $code $location"
|
die "/login/keycloak should redirect to Keycloak with client_id=petrolyst and PKCE S256, got $code $location"
|
||||||
|
|
||||||
read -r code location < <(probe "$TRAEFIK_HOST" /)
|
read -r code location < <(probe "$TRAEFIK_HOST" /)
|
||||||
[ "$code" = 302 ] && [[ $location == "$KEYCLOAK_REALM_URL/"* || $location == *"/oauth2/start"* ]] ||
|
[ "$code" = 302 ] && [[ $location == "$realm/"* || $location == *"/oauth2/start"* ]] ||
|
||||||
die "https://$TRAEFIK_HOST/ should redirect to Keycloak, got $code $location"
|
die "https://$TRAEFIK_HOST/ should redirect to Keycloak, got $code $location"
|
||||||
|
|
||||||
fm exec -T trino trino --execute 'SELECT count(*) FROM fuel.information_schema.schemata' >/dev/null ||
|
# Superset's own connection "Fuel" (fuel_ro, password from /run/secrets) reads fuel-db and
|
||||||
die "Trino cannot read the fuel catalog"
|
# cannot write to it.
|
||||||
fm exec -T superset curl -fsS --max-time 10 http://trino:8080/v1/info >/dev/null ||
|
fm exec -T superset /app/.venv/bin/python - <<'PY' || die "Superset cannot read fuel-db as fuel_ro, or fuel_ro can write"
|
||||||
die "Superset cannot reach Trino"
|
import sys
|
||||||
|
|
||||||
|
from superset.app import create_app
|
||||||
|
|
||||||
|
with create_app().app_context():
|
||||||
|
from superset import db
|
||||||
|
from superset.models.core import Database
|
||||||
|
|
||||||
|
fuel = db.session.query(Database).filter_by(database_name="Fuel").one()
|
||||||
|
with fuel.get_sqla_engine() as engine, engine.connect() as conn:
|
||||||
|
user, read_only = conn.exec_driver_sql(
|
||||||
|
"SELECT current_user, current_setting('transaction_read_only')").one()
|
||||||
|
sys.exit(0 if (user, read_only) == ("fuel_ro", "on") else 1)
|
||||||
|
PY
|
||||||
log "smoke tests passed"
|
log "smoke tests passed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,27 +2,17 @@
|
|||||||
# One-time host preparation for the petrolyst server (Ubuntu 24.04), run as root.
|
# One-time host preparation for the petrolyst server (Ubuntu 24.04), run as root.
|
||||||
# Idempotent: re-running converges to the same state.
|
# 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)
|
# Deploys are manual: a person runs them over root SSH, and root hands over to
|
||||||
# --runner-ip egress IP of the Gitea act_runner; the deploy key works only from
|
# the deploy user (README, "Manual deploy"). There is no CI runner, so deploy
|
||||||
# there ("any" drops that restriction, e.g. before the runner exists)
|
# has no SSH key of its own.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
die() { echo "ERROR: $*" >&2; exit 1; }
|
die() { echo "ERROR: $*" >&2; exit 1; }
|
||||||
[ "$(id -u)" = 0 ] || die "run as root"
|
[ "$(id -u)" = 0 ] || die "run as root"
|
||||||
|
|
||||||
ci_key='' runner_ip=''
|
[ $# -eq 0 ] || die "bootstrap.sh takes no arguments (deploys are manual; there is no CI key)"
|
||||||
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"
|
|
||||||
|
|
||||||
ROOT=/opt/petrolyst
|
ROOT=/opt/petrolyst
|
||||||
|
|
||||||
@@ -50,7 +40,7 @@ if [ "$(cat /etc/docker/daemon.json 2>/dev/null)" != "$daemon_json" ]; then
|
|||||||
fi
|
fi
|
||||||
systemctl enable --now docker
|
systemctl enable --now docker
|
||||||
|
|
||||||
echo "== swap (4 GB) for Trino, Superset and two Postgres in 7.8 GB"
|
echo "== swap (4 GB) for Superset and two Postgres in 7.8 GB"
|
||||||
if ! swapon --show=NAME --noheadings | grep -qx /swapfile; then
|
if ! swapon --show=NAME --noheadings | grep -qx /swapfile; then
|
||||||
[ -e /swapfile ] || fallocate -l 4G /swapfile
|
[ -e /swapfile ] || fallocate -l 4G /swapfile
|
||||||
chmod 600 /swapfile
|
chmod 600 /swapfile
|
||||||
@@ -69,15 +59,10 @@ ufw default allow outgoing >/dev/null
|
|||||||
for port in 22 80 443; do ufw allow "$port/tcp" >/dev/null; done
|
for port in 22 80 443; do ufw allow "$port/tcp" >/dev/null; done
|
||||||
ufw --force enable >/dev/null
|
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
|
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
|
usermod -aG docker deploy # docker access makes deploy root-equivalent, so it gets no SSH key
|
||||||
install -d -o deploy -g deploy -m 0700 /home/deploy/.ssh
|
rm -f /home/deploy/.ssh/authorized_keys # the former CI key, if an earlier bootstrap installed it
|
||||||
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
|
|
||||||
|
|
||||||
echo "== SSH: keys only"
|
echo "== SSH: keys only"
|
||||||
[ -s /root/.ssh/authorized_keys ] || die "root has no authorized_keys; refusing to turn off password login"
|
[ -s /root/.ssh/authorized_keys ] || die "root has no authorized_keys; refusing to turn off password login"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Server secrets and env files, run as root after bootstrap.sh.
|
# Server secrets, run as root after bootstrap.sh.
|
||||||
# secrets-init.sh generate every missing secret; never overwrites one
|
# secrets-init.sh generate every missing secret; never overwrites one
|
||||||
# secrets-init.sh keycloak-secret store the client secret of Keycloak client
|
# secrets-init.sh keycloak-secret store the client secret of Keycloak client
|
||||||
# "petrolyst" (Clients > petrolyst > Credentials),
|
# "petrolyst" of realm "petrolyst"
|
||||||
|
# (Clients > petrolyst > Credentials),
|
||||||
# read from the terminal or stdin
|
# read from the terminal or stdin
|
||||||
#
|
#
|
||||||
# Layout: /opt/petrolyst/secrets/{shared,traefik,fuel-monitoring}/ are root:deploy
|
# Layout: /opt/petrolyst/secrets/{shared,traefik,fuel-monitoring}/ are root:deploy
|
||||||
@@ -17,7 +18,7 @@ getent group deploy >/dev/null || die "group deploy is missing: run host/bootstr
|
|||||||
ROOT=/opt/petrolyst
|
ROOT=/opt/petrolyst
|
||||||
SECRETS=$ROOT/secrets
|
SECRETS=$ROOT/secrets
|
||||||
umask 077
|
umask 077
|
||||||
install -d -o root -g deploy -m 0750 "$SECRETS" "$SECRETS/shared" "$SECRETS/traefik" "$SECRETS/fuel-monitoring" "$ROOT/env"
|
install -d -o root -g deploy -m 0750 "$SECRETS" "$SECRETS/shared" "$SECRETS/traefik" "$SECRETS/fuel-monitoring"
|
||||||
|
|
||||||
# write_secret <path> <value>: no trailing newline (oauth2-proxy reads the file
|
# write_secret <path> <value>: no trailing newline (oauth2-proxy reads the file
|
||||||
# byte for byte), replaced atomically.
|
# byte for byte), replaced atomically.
|
||||||
@@ -50,8 +51,8 @@ case ${1:-all} in
|
|||||||
fi
|
fi
|
||||||
value=$(printf '%s' "$value" | tr -d '[:space:]')
|
value=$(printf '%s' "$value" | tr -d '[:space:]')
|
||||||
[ -n "$value" ] || die "empty secret"
|
[ -n "$value" ] || die "empty secret"
|
||||||
write_secret "$SECRETS/shared/keycloak_petrolyst_client_secret" "$value"
|
write_secret "$SECRETS/shared/keycloak_petrolyst_realm_client_secret" "$value"
|
||||||
echo "stored $SECRETS/shared/keycloak_petrolyst_client_secret"
|
echo "stored $SECRETS/shared/keycloak_petrolyst_realm_client_secret"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
all) ;;
|
all) ;;
|
||||||
@@ -62,26 +63,14 @@ esac
|
|||||||
generate "$SECRETS/fuel-monitoring/superset_secret_key" 32
|
generate "$SECRETS/fuel-monitoring/superset_secret_key" 32
|
||||||
generate "$SECRETS/fuel-monitoring/superset_db_password" 32
|
generate "$SECRETS/fuel-monitoring/superset_db_password" 32
|
||||||
generate "$SECRETS/fuel-monitoring/fuel_db_password" 32
|
generate "$SECRETS/fuel-monitoring/fuel_db_password" 32
|
||||||
generate "$SECRETS/fuel-monitoring/fuel_db_trino_password" 32
|
generate "$SECRETS/fuel-monitoring/fuel_db_ro_password" 32
|
||||||
# 32 hex characters = a 32-byte cookie key, as oauth2-proxy requires.
|
# 32 hex characters = a 32-byte cookie key, as oauth2-proxy requires.
|
||||||
generate "$SECRETS/traefik/oauth2_proxy_cookie_secret" 16
|
generate "$SECRETS/traefik/oauth2_proxy_cookie_secret" 16
|
||||||
|
|
||||||
# Trino reads the catalog password from its environment (${ENV:...}); keep the
|
if [ ! -s "$SECRETS/shared/keycloak_petrolyst_realm_client_secret" ]; then
|
||||||
# env file in step with the secret file that fuel-db's init script uses.
|
|
||||||
env_file=$ROOT/env/fuel-monitoring.env
|
|
||||||
line="FUEL_DB_TRINO_PASSWORD=$(cat "$SECRETS/fuel-monitoring/fuel_db_trino_password")"
|
|
||||||
if ! grep -qxF "$line" "$env_file" 2>/dev/null; then
|
|
||||||
printf '%s\n' "$line" >"$env_file.tmp"
|
|
||||||
chown root:deploy "$env_file.tmp"
|
|
||||||
chmod 0440 "$env_file.tmp"
|
|
||||||
mv "$env_file.tmp" "$env_file"
|
|
||||||
echo "wrote $env_file"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -s "$SECRETS/shared/keycloak_petrolyst_client_secret" ]; then
|
|
||||||
echo
|
echo
|
||||||
echo "Still missing: the Keycloak client secret. Copy it from Keycloak"
|
echo "Still missing: the Keycloak client secret. Copy it from Keycloak"
|
||||||
echo "(realm thedigitalbridge > Clients > petrolyst > Credentials), then run:"
|
echo "(realm petrolyst > Clients > petrolyst > Credentials), then run:"
|
||||||
echo " $0 keycloak-secret"
|
echo " $0 keycloak-secret"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Built by build.sh
|
||||||
|
dist/
|
||||||
|
# Satoshi: licence forbids committing it (build.sh)
|
||||||
|
petrolyst/login/resources/fonts/Satoshi-*
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
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
|
||||||
@@ -15,8 +15,9 @@ and where it runs lives in `stacks/`, `traefik/`, `deploy/` and `host/`.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `superset_config.py` | Keycloak sign-in, role mapping, proxy and cookie settings, Redis caches, theme and chart palettes | `/app/pythonpath/` |
|
| `superset_config.py` | Keycloak sign-in, role mapping, proxy and cookie settings, Redis caches, theme and chart palettes | `/app/pythonpath/` |
|
||||||
| `petrolyst_security.py` | `PetrolystSecurityManager`: user info and roles from the Keycloak ID token, `/login/` straight to Keycloak, `/logout/` through Keycloak | `/app/pythonpath/` |
|
| `petrolyst_security.py` | `PetrolystSecurityManager`: user info and roles from the Keycloak ID token, `/login/` straight to Keycloak, `/logout/` through Keycloak | `/app/pythonpath/` |
|
||||||
| `assets/branding/` | logos (full colour and white), favicons, app icons | `/app/superset/static/assets/branding/`, served at `/static/assets/branding/…` |
|
| `roles/` | `petrolyst_guest.py`: the role "Petrolyst Guest" for the Petrolyst app's embedded dashboards, with exactly its permissions; run by hand (see "Embedding") | `/app/petrolyst-roles/` |
|
||||||
| `patches/` | runtime patches, `fix_*.py` with `--verify` (see its README); empty at start | `/app/patches/petrolyst/`, applied during the build |
|
| `assets/branding/` | logos (full colour and white): the wordmark alone for the navbar, and the stacked logo; favicons, app icons; ATOMA's logo, which the Petrolyst app shows in its map's corner (the server reads this file itself: `PETROLYST_OPERATOR_LOGO_FILE`) | `/app/superset/static/assets/branding/`, served at `/static/assets/branding/…` |
|
||||||
|
| `patches/` | runtime patches, `fix_*.py` with `--verify` (see its README); none at present | `/app/patches/petrolyst/`, applied during the build |
|
||||||
| `dashboards/` | exported dashboards and import scripts (see its README) | not in the image |
|
| `dashboards/` | exported dashboards and import scripts (see its README) | not in the image |
|
||||||
|
|
||||||
How the image consumes this folder: `stacks/fuel-monitoring/compose.yaml` passes it to the build
|
How the image consumes this folder: `stacks/fuel-monitoring/compose.yaml` passes it to the build
|
||||||
@@ -34,13 +35,13 @@ value.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `superset_secret_key` | Flask `SECRET_KEY` |
|
| `superset_secret_key` | Flask `SECRET_KEY` |
|
||||||
| `superset_db_password` | password of user `superset` on the metadata database |
|
| `superset_db_password` | password of user `superset` on the metadata database |
|
||||||
| `keycloak_petrolyst_client_secret` | client secret of Keycloak client `petrolyst` |
|
| `keycloak_petrolyst_client_secret` | client secret of Keycloak client `petrolyst`, realm `petrolyst`; on the server in `shared/keycloak_petrolyst_realm_client_secret` |
|
||||||
|
|
||||||
On the server they come from `/opt/petrolyst/secrets/` (see the deployment README).
|
On the server they come from `/opt/petrolyst/secrets/` (see the deployment README).
|
||||||
|
|
||||||
## Sign-in and roles
|
## Sign-in and roles
|
||||||
|
|
||||||
- Provider: Keycloak realm `thedigitalbridge` at `iam.thedigitalbridge.io`, client `petrolyst`,
|
- Provider: Keycloak realm `petrolyst` at `iam.thedigitalbridge.io`, client `petrolyst`,
|
||||||
authorization code flow with PKCE S256. The realm URL can be overridden with
|
authorization code flow with PKCE S256. The realm URL can be overridden with
|
||||||
`KEYCLOAK_REALM_URL`.
|
`KEYCLOAK_REALM_URL`.
|
||||||
- Roles come from the client roles of `petrolyst`, which a protocol mapper puts into the claim
|
- Roles come from the client roles of `petrolyst`, which a protocol mapper puts into the claim
|
||||||
@@ -51,20 +52,44 @@ On the server they come from `/opt/petrolyst/secrets/` (see the deployment READM
|
|||||||
| `superset-admin` | Admin |
|
| `superset-admin` | Admin |
|
||||||
| `superset-editor` | Alpha |
|
| `superset-editor` | Alpha |
|
||||||
| `superset-viewer` | Gamma |
|
| `superset-viewer` | Gamma |
|
||||||
|
| `atoma-reader` | none (Public): ATOMA's users use the Petrolyst app, not Superset (PD-13) |
|
||||||
| none | Public (no permissions) |
|
| none | Public (no permissions) |
|
||||||
|
|
||||||
- `DASHBOARD_RBAC` is on: a viewer sees a dashboard once an admin adds the Gamma role (or a
|
- `DASHBOARD_RBAC` is on: a viewer sees a dashboard once an admin adds the Gamma role (or a
|
||||||
custom one) to it.
|
custom one) to it.
|
||||||
|
- `FILTERBAR_CLOSED_BY_DEFAULT` is on: a dashboard's filter column opens collapsed, a narrow
|
||||||
|
strip with an expand button; `?expand_filters=true` opens it from a link.
|
||||||
|
|
||||||
|
## Embedding (local trial only)
|
||||||
|
|
||||||
|
The Petrolyst app (PD-12, petrolyst-platform `app/` and `server/`) shows dashboards inside
|
||||||
|
its Insights lens with Superset guest passes. This is switched on **only in the local scratch
|
||||||
|
stack** (`.local/fmdb-scratch`); `superset_config.py` here leaves it off, and loads without the
|
||||||
|
guest secret.
|
||||||
|
|
||||||
|
- The scratch stack's `superset_config_local.py` turns on `EMBEDDED_SUPERSET`, names the guest
|
||||||
|
role `GUEST_ROLE_NAME = "Petrolyst Guest"`, reads the signing secret with
|
||||||
|
`_read_secret("superset_guest_token_secret")` and expects the audience `petrolyst-app`. The
|
||||||
|
Petrolyst server signs the guest passes itself with the same secret.
|
||||||
|
- `roles/petrolyst_guest.py` gives "Petrolyst Guest" exactly what a dashboard needs to be read. Which
|
||||||
|
dashboard a guest sees comes from the pass, not the role. It is not in `init.sh`; run it once,
|
||||||
|
and again whenever its permissions change:
|
||||||
|
|
||||||
|
docker exec <superset> /app/.venv/bin/python /app/petrolyst-roles/petrolyst_guest.py
|
||||||
|
|
||||||
|
- The app's dashboards and their embedding ids: `dashboards/README.md`.
|
||||||
|
|
||||||
|
Production embedding is an open point of PD-12 (`.context/design-decisions.md`).
|
||||||
|
|
||||||
## Look and feel
|
## Look and feel
|
||||||
|
|
||||||
Source: `petrolyst-ui/design/brand/` (tokens `07_UI_Tokens/petrolyst_tokens.css`, logos
|
Source: `petrolyst-web/design/brand/` (tokens `07_UI_Tokens/petrolyst_tokens.css`, logos
|
||||||
`public/brand/`, favicons `03_Favicon_App_Icons/`), copied on 2026-10-23 from petrolyst-ui commit
|
`public/brand/`, favicons `03_Favicon_App_Icons/`), copied on 2026-10-23 from petrolyst-web (then petrolyst-ui) commit
|
||||||
`2b2e694`. Copy again when the brand changes.
|
`2b2e694`. Copy again when the brand changes.
|
||||||
|
|
||||||
- Light theme: action teal `#00695C` for primary and links, navy `#0B1D33` text, `#F8FAFC`
|
- Light theme: action teal `#00695C` for primary and links, navy `#0B1D33` text, `#F8FAFC`
|
||||||
layout, full-colour stacked logo.
|
layout, full-colour stacked logo.
|
||||||
- Dark theme: `#35C5B6` primary, `#071425` / `#12263D` surfaces, white logo.
|
- Dark theme: `#35C5B6` primary, `#1F2A33` / `#2A3945` surfaces (PD-26), white logo with the Y in its green.
|
||||||
- Font: Inter, Superset's default. Satoshi is licence-restricted and is not used.
|
- Font: Inter, Superset's default. Satoshi is licence-restricted and is not used.
|
||||||
- Chart palettes: "Petrolyst" categorical (the default, brand teal first) and "Petrolyst Teal" /
|
- Chart palettes: "Petrolyst" categorical (the default, brand teal first) and "Petrolyst Teal" /
|
||||||
"Petrolyst Navy" sequential. The categorical palette was validated with the dataviz skill's
|
"Petrolyst Navy" sequential. The categorical palette was validated with the dataviz skill's
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="300 225 1900 520" role="img" aria-label="ATOMA">
|
||||||
|
<!-- ATOMA's logo as published on atoma.com.af (mtn-logo.svg, sha256 7a718cdc163c4c59...),
|
||||||
|
in its own colours; only the viewBox is cropped to the logo. Shown in the fleet map's
|
||||||
|
corner (dashboards/atoma/build_bundle.py, FLEET_MAP_SRC). -->
|
||||||
|
<path fill="#1229B3" d="M477.499,608.807h82.857l-82.857-248.571h-82.857l-82.857,248.571h82.857l5.524-16.571h71.809
|
||||||
|
L477.499,608.807z M422.261,525.95l13.81-41.428l13.809,41.428H422.261z"/>
|
||||||
|
<path fill="#1229B3" d="M1526.188,608.807h82.856l-82.856-248.571h-82.857l-82.856,248.571h82.856l5.524-16.571h71.809
|
||||||
|
L1526.188,608.807z M1470.95,525.95l13.81-41.428l13.81,41.428H1470.95z"/>
|
||||||
|
<polygon fill="#1229B3" points="792.355,360.236 709.498,360.236 626.641,360.236 543.784,360.236 543.784,443.093 626.641,443.093
|
||||||
|
626.641,608.808 709.498,608.808 709.498,443.093 792.355,443.093 "/>
|
||||||
|
<g>
|
||||||
|
<defs>
|
||||||
|
<rect id="SVGID_1_" width="2500" height="1000"/>
|
||||||
|
</defs>
|
||||||
|
<clipPath id="SVGID_2_">
|
||||||
|
<use xlink:href="#SVGID_1_" overflow="visible"/>
|
||||||
|
</clipPath>
|
||||||
|
<path clip-path="url(#SVGID_2_)" fill="#1229B3" d="M933.212,360.236c-68.641,0-124.285,55.645-124.285,124.286
|
||||||
|
c0,68.64,55.644,124.285,124.285,124.285s124.285-55.645,124.285-124.285C1057.497,415.881,1001.853,360.236,933.212,360.236
|
||||||
|
M933.212,525.95c-22.88,0-41.428-18.548-41.428-41.428c0-22.881,18.548-41.429,41.428-41.429s41.428,18.548,41.428,41.429
|
||||||
|
C974.64,507.402,956.092,525.95,933.212,525.95"/>
|
||||||
|
<polygon clip-path="url(#SVGID_2_)" fill="#1229B3" points="1248.068,360.236 1206.64,401.665 1165.211,360.236 1082.354,360.236
|
||||||
|
1082.354,608.808 1165.211,608.808 1165.211,484.521 1206.64,525.95 1248.068,484.521 1248.068,608.808 1330.925,608.808
|
||||||
|
1330.925,360.236 "/>
|
||||||
|
<path clip-path="url(#SVGID_2_)" fill="#00C8FF" d="M1929.197,240.505l-106.099,106.098c-5.857,5.858-5.857,15.356,0,21.214
|
||||||
|
l116.705,116.705l-116.705,116.705c-5.857,5.858-5.857,15.356,0,21.214l106.099,106.098c5.857,5.858,15.355,5.858,21.214,0
|
||||||
|
l116.705-116.704l116.704-116.705c5.858-5.858,5.858-15.356,0-21.214L2067.116,357.21l-116.705-116.705
|
||||||
|
C1944.553,234.647,1935.055,234.647,1929.197,240.505"/>
|
||||||
|
<path clip-path="url(#SVGID_2_)" fill="#2449FB" d="M2056.782,494.855l-116.978,116.978l-244.291-244.29
|
||||||
|
c-5.707-5.707-5.707-14.96,0-20.667l106.645-106.645c5.707-5.707,14.96-5.707,20.667,0l233.957,233.957
|
||||||
|
C2062.489,479.895,2062.489,489.148,2056.782,494.855"/>
|
||||||
|
<path clip-path="url(#SVGID_2_)" fill="#1229B3" d="M1802.158,728.812l-106.645-106.645c-5.707-5.707-5.707-14.96,0-20.667
|
||||||
|
l116.978-116.978l127.313,127.312l-116.978,116.978C1817.119,734.519,1807.865,734.519,1802.158,728.812"/>
|
||||||
|
<path clip-path="url(#SVGID_2_)" fill="#00AAFF" d="M1823.1,346.603c-5.858,5.858-5.858,15.356,0,21.214l116.705,116.705
|
||||||
|
L1823.1,601.227c-5.858,5.858-5.858,15.356,0,21.214l53.049,53.049l127.312-127.312l53.049-53.049
|
||||||
|
c5.858-5.858,5.858-15.356,0-21.214l-53.049-53.049l-127.312-127.312L1823.1,346.603z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst">
|
||||||
|
<!-- Petrolyst's wordmark alone: petrolyst-web/design/brand/02_Logo/Vector_Master/
|
||||||
|
petrolyst_logo_standard_fullcolor_no_tagline.svg without the Y mark above it, paths and
|
||||||
|
colours unchanged, the viewBox cropped to the letters. The navbar logo (superset_config.py). -->
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wordYGradient" x1="945" y1="660" x2="1090" y2="780" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#009688"/>
|
||||||
|
<stop offset="100%" stop-color="#00B6E6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g fill="#0B1D33">
|
||||||
|
<path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<path d="M 250.0,715.0 L 250.0,729.0 L 266.0,729.0 L 267.0,730.0 L 301.0,730.0 L 302.0,729.0 L 307.0,729.0 L 308.0,730.0 L 325.0,730.0 L 326.0,729.0 L 340.0,729.0 L 340.0,716.0 L 339.0,715.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<path d="M 1260.0,678.0 L 1260.0,692.0 L 1264.0,692.0 L 1265.0,693.0 L 1296.0,693.0 L 1297.0,694.0 L 1297.0,767.0 L 1313.0,767.0 L 1313.0,694.0 L 1314.0,693.0 L 1351.0,693.0 L 1351.0,678.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<path d="M 867.0,678.0 L 867.0,767.0 L 951.0,767.0 L 951.0,753.0 L 950.0,752.0 L 883.0,752.0 L 882.0,751.0 L 882.0,678.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<path d="M 551.0,678.0 L 551.0,767.0 L 566.0,767.0 L 566.0,743.0 L 567.0,742.0 L 567.0,739.0 L 566.0,738.0 L 567.0,737.0 L 604.0,737.0 L 614.0,747.0 L 614.0,748.0 L 622.0,756.0 L 622.0,757.0 L 630.0,765.0 L 630.0,766.0 L 631.0,767.0 L 651.0,767.0 L 651.0,766.0 L 649.0,764.0 L 649.0,763.0 L 642.0,756.0 L 642.0,755.0 L 628.0,741.0 L 628.0,740.0 L 626.0,738.0 L 628.0,736.0 L 630.0,736.0 L 631.0,735.0 L 633.0,735.0 L 634.0,734.0 L 636.0,734.0 L 638.0,732.0 L 639.0,732.0 L 645.0,726.0 L 645.0,725.0 L 648.0,720.0 L 648.0,718.0 L 649.0,717.0 L 649.0,713.0 L 650.0,712.0 L 650.0,703.0 L 649.0,702.0 L 649.0,698.0 L 648.0,697.0 L 648.0,695.0 L 647.0,694.0 L 646.0,691.0 L 644.0,689.0 L 644.0,688.0 L 640.0,684.0 L 639.0,684.0 L 637.0,682.0 L 636.0,682.0 L 633.0,680.0 L 631.0,680.0 L 630.0,679.0 L 626.0,679.0 L 625.0,678.0 Z M 567.0,694.0 L 568.0,693.0 L 625.0,693.0 L 626.0,694.0 L 629.0,695.0 L 632.0,698.0 L 632.0,699.0 L 633.0,700.0 L 633.0,713.0 L 632.0,714.0 L 632.0,716.0 L 630.0,718.0 L 630.0,719.0 L 629.0,720.0 L 628.0,720.0 L 626.0,722.0 L 622.0,722.0 L 621.0,723.0 L 568.0,723.0 L 566.0,721.0 L 566.0,703.0 L 567.0,702.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<path d="M 398.0,678.0 L 398.0,693.0 L 434.0,693.0 L 435.0,694.0 L 435.0,767.0 L 451.0,767.0 L 451.0,694.0 L 452.0,693.0 L 489.0,693.0 L 489.0,679.0 L 488.0,678.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<path d="M 250.0,678.0 L 250.0,692.0 L 251.0,692.0 L 252.0,693.0 L 339.0,693.0 L 339.0,691.0 L 340.0,690.0 L 340.0,679.0 L 339.0,678.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<path d="M 1134.0,683.0 L 1129.0,688.0 L 1129.0,689.0 L 1127.0,691.0 L 1127.0,693.0 L 1126.0,694.0 L 1126.0,696.0 L 1125.0,697.0 L 1125.0,712.0 L 1126.0,713.0 L 1126.0,715.0 L 1127.0,716.0 L 1128.0,719.0 L 1134.0,725.0 L 1135.0,725.0 L 1137.0,727.0 L 1139.0,727.0 L 1140.0,728.0 L 1143.0,728.0 L 1144.0,729.0 L 1185.0,729.0 L 1186.0,730.0 L 1191.0,730.0 L 1192.0,731.0 L 1193.0,731.0 L 1197.0,735.0 L 1197.0,739.0 L 1198.0,740.0 L 1198.0,742.0 L 1197.0,743.0 L 1197.0,746.0 L 1196.0,747.0 L 1196.0,748.0 L 1193.0,751.0 L 1192.0,751.0 L 1189.0,753.0 L 1184.0,753.0 L 1183.0,754.0 L 1151.0,754.0 L 1150.0,753.0 L 1143.0,753.0 L 1142.0,752.0 L 1139.0,752.0 L 1138.0,751.0 L 1135.0,751.0 L 1134.0,750.0 L 1131.0,750.0 L 1130.0,749.0 L 1128.0,749.0 L 1127.0,748.0 L 1125.0,748.0 L 1125.0,760.0 L 1124.0,761.0 L 1126.0,763.0 L 1128.0,763.0 L 1129.0,764.0 L 1131.0,764.0 L 1132.0,765.0 L 1134.0,765.0 L 1135.0,766.0 L 1139.0,766.0 L 1140.0,767.0 L 1146.0,767.0 L 1147.0,768.0 L 1186.0,768.0 L 1187.0,767.0 L 1191.0,767.0 L 1192.0,766.0 L 1195.0,766.0 L 1196.0,765.0 L 1198.0,765.0 L 1199.0,764.0 L 1201.0,764.0 L 1203.0,762.0 L 1204.0,762.0 L 1209.0,757.0 L 1209.0,756.0 L 1211.0,754.0 L 1211.0,753.0 L 1212.0,752.0 L 1212.0,750.0 L 1213.0,749.0 L 1213.0,734.0 L 1212.0,733.0 L 1212.0,730.0 L 1211.0,729.0 L 1211.0,728.0 L 1209.0,726.0 L 1209.0,725.0 L 1204.0,720.0 L 1203.0,720.0 L 1202.0,719.0 L 1201.0,719.0 L 1196.0,716.0 L 1192.0,716.0 L 1191.0,715.0 L 1148.0,715.0 L 1147.0,714.0 L 1145.0,714.0 L 1142.0,711.0 L 1142.0,710.0 L 1141.0,709.0 L 1141.0,699.0 L 1142.0,698.0 L 1142.0,697.0 L 1145.0,694.0 L 1146.0,694.0 L 1147.0,693.0 L 1150.0,693.0 L 1151.0,692.0 L 1190.0,692.0 L 1191.0,693.0 L 1196.0,693.0 L 1197.0,694.0 L 1201.0,694.0 L 1202.0,695.0 L 1204.0,695.0 L 1205.0,696.0 L 1209.0,696.0 L 1209.0,683.0 L 1206.0,681.0 L 1203.0,681.0 L 1202.0,680.0 L 1198.0,680.0 L 1197.0,679.0 L 1193.0,679.0 L 1192.0,678.0 L 1182.0,678.0 L 1181.0,677.0 L 1156.0,677.0 L 1155.0,678.0 L 1146.0,678.0 L 1145.0,679.0 L 1143.0,679.0 L 1142.0,680.0 L 1140.0,680.0 L 1139.0,681.0 L 1138.0,681.0 L 1137.0,682.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<path d="M 714.0,691.0 L 714.0,692.0 L 711.0,695.0 L 711.0,696.0 L 709.0,698.0 L 709.0,699.0 L 708.0,700.0 L 708.0,702.0 L 707.0,703.0 L 707.0,706.0 L 706.0,707.0 L 706.0,711.0 L 705.0,712.0 L 705.0,734.0 L 706.0,735.0 L 706.0,739.0 L 707.0,740.0 L 707.0,743.0 L 708.0,744.0 L 708.0,745.0 L 709.0,746.0 L 709.0,747.0 L 710.0,748.0 L 711.0,751.0 L 713.0,753.0 L 713.0,754.0 L 718.0,759.0 L 719.0,759.0 L 721.0,761.0 L 722.0,761.0 L 723.0,762.0 L 724.0,762.0 L 729.0,765.0 L 731.0,765.0 L 732.0,766.0 L 735.0,766.0 L 736.0,767.0 L 741.0,767.0 L 742.0,768.0 L 775.0,768.0 L 776.0,767.0 L 781.0,767.0 L 782.0,766.0 L 785.0,766.0 L 786.0,765.0 L 788.0,765.0 L 789.0,764.0 L 790.0,764.0 L 791.0,763.0 L 792.0,763.0 L 793.0,762.0 L 796.0,761.0 L 798.0,759.0 L 799.0,759.0 L 805.0,753.0 L 805.0,752.0 L 807.0,750.0 L 807.0,749.0 L 809.0,746.0 L 809.0,744.0 L 811.0,741.0 L 811.0,737.0 L 812.0,736.0 L 812.0,711.0 L 811.0,710.0 L 811.0,706.0 L 810.0,705.0 L 810.0,703.0 L 809.0,702.0 L 809.0,700.0 L 808.0,699.0 L 808.0,698.0 L 806.0,696.0 L 806.0,695.0 L 803.0,692.0 L 803.0,691.0 L 799.0,687.0 L 798.0,687.0 L 793.0,683.0 L 792.0,683.0 L 791.0,682.0 L 789.0,682.0 L 786.0,680.0 L 783.0,680.0 L 782.0,679.0 L 779.0,679.0 L 778.0,678.0 L 770.0,678.0 L 769.0,677.0 L 748.0,677.0 L 747.0,678.0 L 739.0,678.0 L 738.0,679.0 L 734.0,679.0 L 733.0,680.0 L 731.0,680.0 L 730.0,681.0 L 728.0,681.0 L 727.0,682.0 L 726.0,682.0 L 725.0,683.0 L 722.0,684.0 L 720.0,686.0 L 719.0,686.0 Z M 725.0,702.0 L 730.0,697.0 L 731.0,697.0 L 733.0,695.0 L 735.0,695.0 L 738.0,693.0 L 742.0,693.0 L 743.0,692.0 L 772.0,692.0 L 773.0,693.0 L 777.0,693.0 L 778.0,694.0 L 781.0,694.0 L 782.0,695.0 L 785.0,696.0 L 787.0,698.0 L 788.0,698.0 L 791.0,701.0 L 791.0,702.0 L 793.0,704.0 L 793.0,705.0 L 794.0,706.0 L 794.0,708.0 L 795.0,709.0 L 795.0,712.0 L 796.0,713.0 L 796.0,733.0 L 795.0,734.0 L 795.0,738.0 L 794.0,739.0 L 793.0,742.0 L 791.0,744.0 L 791.0,745.0 L 788.0,748.0 L 787.0,748.0 L 785.0,750.0 L 784.0,750.0 L 781.0,752.0 L 778.0,752.0 L 777.0,753.0 L 770.0,753.0 L 769.0,754.0 L 747.0,754.0 L 746.0,753.0 L 740.0,753.0 L 739.0,752.0 L 737.0,752.0 L 736.0,751.0 L 734.0,751.0 L 733.0,750.0 L 732.0,750.0 L 730.0,748.0 L 729.0,748.0 L 725.0,744.0 L 725.0,743.0 L 722.0,738.0 L 722.0,734.0 L 721.0,733.0 L 721.0,713.0 L 722.0,712.0 L 722.0,709.0 L 723.0,708.0 L 723.0,706.0 L 724.0,705.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<path d="M 95.0,678.0 L 95.0,766.0 L 96.0,767.0 L 111.0,767.0 L 111.0,737.0 L 112.0,736.0 L 171.0,736.0 L 172.0,735.0 L 175.0,735.0 L 176.0,734.0 L 178.0,734.0 L 179.0,733.0 L 180.0,733.0 L 182.0,731.0 L 183.0,731.0 L 186.0,728.0 L 187.0,728.0 L 187.0,727.0 L 190.0,724.0 L 190.0,723.0 L 193.0,718.0 L 193.0,715.0 L 194.0,714.0 L 194.0,702.0 L 193.0,701.0 L 193.0,696.0 L 192.0,695.0 L 192.0,693.0 L 191.0,692.0 L 190.0,689.0 L 188.0,687.0 L 188.0,686.0 L 185.0,683.0 L 184.0,683.0 L 182.0,681.0 L 181.0,681.0 L 180.0,680.0 L 178.0,680.0 L 177.0,679.0 L 174.0,679.0 L 173.0,678.0 L 168.0,678.0 L 167.0,677.0 L 156.0,677.0 L 155.0,678.0 L 135.0,678.0 L 134.0,677.0 L 132.0,677.0 L 131.0,678.0 L 108.0,678.0 L 107.0,677.0 L 105.0,677.0 L 104.0,678.0 Z M 111.0,693.0 L 112.0,692.0 L 169.0,692.0 L 170.0,693.0 L 172.0,693.0 L 177.0,698.0 L 177.0,699.0 L 178.0,700.0 L 178.0,712.0 L 177.0,713.0 L 177.0,715.0 L 176.0,716.0 L 176.0,717.0 L 174.0,719.0 L 173.0,719.0 L 170.0,721.0 L 167.0,721.0 L 166.0,722.0 L 154.0,722.0 L 153.0,721.0 L 152.0,722.0 L 115.0,722.0 L 114.0,721.0 L 113.0,722.0 L 111.0,720.0 Z" fill="#0B1D33" fill-rule="evenodd"/>
|
||||||
|
<rect x="250" y="678" width="16" height="90" fill="#0B1D33"/>
|
||||||
|
</g>
|
||||||
|
<path d="M 1084.0,678.0 L 1063.0,678.0 L 1056.0,685.0 L 1056.0,686.0 L 1049.0,693.0 L 1049.0,694.0 L 1042.0,701.0 L 1042.0,702.0 L 1036.0,708.0 L 1036.0,709.0 L 1035.0,710.0 L 1035.0,711.0 L 1033.0,713.0 L 1033.0,714.0 L 1032.0,715.0 L 1032.0,716.0 L 1031.0,717.0 L 1031.0,718.0 L 1030.0,719.0 L 1030.0,720.0 L 1028.0,723.0 L 1028.0,725.0 L 1026.0,728.0 L 1026.0,730.0 L 1025.0,731.0 L 1025.0,733.0 L 1024.0,734.0 L 1024.0,738.0 L 1023.0,739.0 L 1023.0,743.0 L 1022.0,744.0 L 1022.0,752.0 L 1021.0,753.0 L 1021.0,766.0 L 1022.0,767.0 L 1038.0,767.0 L 1038.0,737.0 L 1039.0,736.0 L 1039.0,734.0 L 1040.0,733.0 L 1040.0,732.0 L 1041.0,731.0 L 1041.0,730.0 L 1043.0,728.0 L 1043.0,727.0 L 1046.0,724.0 L 1046.0,723.0 L 1052.0,717.0 L 1052.0,716.0 L 1060.0,708.0 L 1060.0,707.0 L 1065.0,702.0 L 1065.0,701.0 L 1072.0,694.0 L 1072.0,693.0 L 1077.0,688.0 L 1077.0,687.0 L 1082.0,682.0 L 1082.0,681.0 L 1084.0,679.0 L 1085.0,679.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
|
||||||
|
<path d="M 975.0,678.0 L 975.0,679.0 L 979.0,683.0 L 979.0,684.0 L 983.0,688.0 L 983.0,689.0 L 990.0,696.0 L 990.0,697.0 L 996.0,703.0 L 996.0,704.0 L 1002.0,710.0 L 1002.0,711.0 L 1010.0,719.0 L 1010.0,720.0 L 1014.0,724.0 L 1014.0,725.0 L 1017.0,728.0 L 1017.0,729.0 L 1018.0,730.0 L 1018.0,731.0 L 1019.0,732.0 L 1020.0,732.0 L 1020.0,729.0 L 1021.0,728.0 L 1021.0,726.0 L 1022.0,725.0 L 1022.0,722.0 L 1023.0,721.0 L 1023.0,715.0 L 1024.0,714.0 L 1023.0,713.0 L 1023.0,709.0 L 1022.0,708.0 L 1022.0,707.0 L 1021.0,706.0 L 1021.0,705.0 L 1016.0,700.0 L 1016.0,699.0 L 1010.0,693.0 L 1010.0,692.0 L 1003.0,685.0 L 1003.0,684.0 L 997.0,678.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst">
|
||||||
|
<!-- Petrolyst's wordmark alone: petrolyst-web/design/brand/02_Logo/Vector_Master/
|
||||||
|
petrolyst_logo_standard_white_no_tagline.svg without the Y mark above it, paths and
|
||||||
|
colours unchanged, the viewBox cropped to the letters. The navbar logo (superset_config.py). -->
|
||||||
|
<g fill="#FFFFFF"><path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 250.0,715.0 L 250.0,729.0 L 266.0,729.0 L 267.0,730.0 L 301.0,730.0 L 302.0,729.0 L 307.0,729.0 L 308.0,730.0 L 325.0,730.0 L 326.0,729.0 L 340.0,729.0 L 340.0,716.0 L 339.0,715.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 1260.0,678.0 L 1260.0,692.0 L 1264.0,692.0 L 1265.0,693.0 L 1296.0,693.0 L 1297.0,694.0 L 1297.0,767.0 L 1313.0,767.0 L 1313.0,694.0 L 1314.0,693.0 L 1351.0,693.0 L 1351.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 867.0,678.0 L 867.0,767.0 L 951.0,767.0 L 951.0,753.0 L 950.0,752.0 L 883.0,752.0 L 882.0,751.0 L 882.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 551.0,678.0 L 551.0,767.0 L 566.0,767.0 L 566.0,743.0 L 567.0,742.0 L 567.0,739.0 L 566.0,738.0 L 567.0,737.0 L 604.0,737.0 L 614.0,747.0 L 614.0,748.0 L 622.0,756.0 L 622.0,757.0 L 630.0,765.0 L 630.0,766.0 L 631.0,767.0 L 651.0,767.0 L 651.0,766.0 L 649.0,764.0 L 649.0,763.0 L 642.0,756.0 L 642.0,755.0 L 628.0,741.0 L 628.0,740.0 L 626.0,738.0 L 628.0,736.0 L 630.0,736.0 L 631.0,735.0 L 633.0,735.0 L 634.0,734.0 L 636.0,734.0 L 638.0,732.0 L 639.0,732.0 L 645.0,726.0 L 645.0,725.0 L 648.0,720.0 L 648.0,718.0 L 649.0,717.0 L 649.0,713.0 L 650.0,712.0 L 650.0,703.0 L 649.0,702.0 L 649.0,698.0 L 648.0,697.0 L 648.0,695.0 L 647.0,694.0 L 646.0,691.0 L 644.0,689.0 L 644.0,688.0 L 640.0,684.0 L 639.0,684.0 L 637.0,682.0 L 636.0,682.0 L 633.0,680.0 L 631.0,680.0 L 630.0,679.0 L 626.0,679.0 L 625.0,678.0 Z M 567.0,694.0 L 568.0,693.0 L 625.0,693.0 L 626.0,694.0 L 629.0,695.0 L 632.0,698.0 L 632.0,699.0 L 633.0,700.0 L 633.0,713.0 L 632.0,714.0 L 632.0,716.0 L 630.0,718.0 L 630.0,719.0 L 629.0,720.0 L 628.0,720.0 L 626.0,722.0 L 622.0,722.0 L 621.0,723.0 L 568.0,723.0 L 566.0,721.0 L 566.0,703.0 L 567.0,702.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 398.0,678.0 L 398.0,693.0 L 434.0,693.0 L 435.0,694.0 L 435.0,767.0 L 451.0,767.0 L 451.0,694.0 L 452.0,693.0 L 489.0,693.0 L 489.0,679.0 L 488.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 250.0,678.0 L 250.0,692.0 L 251.0,692.0 L 252.0,693.0 L 339.0,693.0 L 339.0,691.0 L 340.0,690.0 L 340.0,679.0 L 339.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 1134.0,683.0 L 1129.0,688.0 L 1129.0,689.0 L 1127.0,691.0 L 1127.0,693.0 L 1126.0,694.0 L 1126.0,696.0 L 1125.0,697.0 L 1125.0,712.0 L 1126.0,713.0 L 1126.0,715.0 L 1127.0,716.0 L 1128.0,719.0 L 1134.0,725.0 L 1135.0,725.0 L 1137.0,727.0 L 1139.0,727.0 L 1140.0,728.0 L 1143.0,728.0 L 1144.0,729.0 L 1185.0,729.0 L 1186.0,730.0 L 1191.0,730.0 L 1192.0,731.0 L 1193.0,731.0 L 1197.0,735.0 L 1197.0,739.0 L 1198.0,740.0 L 1198.0,742.0 L 1197.0,743.0 L 1197.0,746.0 L 1196.0,747.0 L 1196.0,748.0 L 1193.0,751.0 L 1192.0,751.0 L 1189.0,753.0 L 1184.0,753.0 L 1183.0,754.0 L 1151.0,754.0 L 1150.0,753.0 L 1143.0,753.0 L 1142.0,752.0 L 1139.0,752.0 L 1138.0,751.0 L 1135.0,751.0 L 1134.0,750.0 L 1131.0,750.0 L 1130.0,749.0 L 1128.0,749.0 L 1127.0,748.0 L 1125.0,748.0 L 1125.0,760.0 L 1124.0,761.0 L 1126.0,763.0 L 1128.0,763.0 L 1129.0,764.0 L 1131.0,764.0 L 1132.0,765.0 L 1134.0,765.0 L 1135.0,766.0 L 1139.0,766.0 L 1140.0,767.0 L 1146.0,767.0 L 1147.0,768.0 L 1186.0,768.0 L 1187.0,767.0 L 1191.0,767.0 L 1192.0,766.0 L 1195.0,766.0 L 1196.0,765.0 L 1198.0,765.0 L 1199.0,764.0 L 1201.0,764.0 L 1203.0,762.0 L 1204.0,762.0 L 1209.0,757.0 L 1209.0,756.0 L 1211.0,754.0 L 1211.0,753.0 L 1212.0,752.0 L 1212.0,750.0 L 1213.0,749.0 L 1213.0,734.0 L 1212.0,733.0 L 1212.0,730.0 L 1211.0,729.0 L 1211.0,728.0 L 1209.0,726.0 L 1209.0,725.0 L 1204.0,720.0 L 1203.0,720.0 L 1202.0,719.0 L 1201.0,719.0 L 1196.0,716.0 L 1192.0,716.0 L 1191.0,715.0 L 1148.0,715.0 L 1147.0,714.0 L 1145.0,714.0 L 1142.0,711.0 L 1142.0,710.0 L 1141.0,709.0 L 1141.0,699.0 L 1142.0,698.0 L 1142.0,697.0 L 1145.0,694.0 L 1146.0,694.0 L 1147.0,693.0 L 1150.0,693.0 L 1151.0,692.0 L 1190.0,692.0 L 1191.0,693.0 L 1196.0,693.0 L 1197.0,694.0 L 1201.0,694.0 L 1202.0,695.0 L 1204.0,695.0 L 1205.0,696.0 L 1209.0,696.0 L 1209.0,683.0 L 1206.0,681.0 L 1203.0,681.0 L 1202.0,680.0 L 1198.0,680.0 L 1197.0,679.0 L 1193.0,679.0 L 1192.0,678.0 L 1182.0,678.0 L 1181.0,677.0 L 1156.0,677.0 L 1155.0,678.0 L 1146.0,678.0 L 1145.0,679.0 L 1143.0,679.0 L 1142.0,680.0 L 1140.0,680.0 L 1139.0,681.0 L 1138.0,681.0 L 1137.0,682.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 714.0,691.0 L 714.0,692.0 L 711.0,695.0 L 711.0,696.0 L 709.0,698.0 L 709.0,699.0 L 708.0,700.0 L 708.0,702.0 L 707.0,703.0 L 707.0,706.0 L 706.0,707.0 L 706.0,711.0 L 705.0,712.0 L 705.0,734.0 L 706.0,735.0 L 706.0,739.0 L 707.0,740.0 L 707.0,743.0 L 708.0,744.0 L 708.0,745.0 L 709.0,746.0 L 709.0,747.0 L 710.0,748.0 L 711.0,751.0 L 713.0,753.0 L 713.0,754.0 L 718.0,759.0 L 719.0,759.0 L 721.0,761.0 L 722.0,761.0 L 723.0,762.0 L 724.0,762.0 L 729.0,765.0 L 731.0,765.0 L 732.0,766.0 L 735.0,766.0 L 736.0,767.0 L 741.0,767.0 L 742.0,768.0 L 775.0,768.0 L 776.0,767.0 L 781.0,767.0 L 782.0,766.0 L 785.0,766.0 L 786.0,765.0 L 788.0,765.0 L 789.0,764.0 L 790.0,764.0 L 791.0,763.0 L 792.0,763.0 L 793.0,762.0 L 796.0,761.0 L 798.0,759.0 L 799.0,759.0 L 805.0,753.0 L 805.0,752.0 L 807.0,750.0 L 807.0,749.0 L 809.0,746.0 L 809.0,744.0 L 811.0,741.0 L 811.0,737.0 L 812.0,736.0 L 812.0,711.0 L 811.0,710.0 L 811.0,706.0 L 810.0,705.0 L 810.0,703.0 L 809.0,702.0 L 809.0,700.0 L 808.0,699.0 L 808.0,698.0 L 806.0,696.0 L 806.0,695.0 L 803.0,692.0 L 803.0,691.0 L 799.0,687.0 L 798.0,687.0 L 793.0,683.0 L 792.0,683.0 L 791.0,682.0 L 789.0,682.0 L 786.0,680.0 L 783.0,680.0 L 782.0,679.0 L 779.0,679.0 L 778.0,678.0 L 770.0,678.0 L 769.0,677.0 L 748.0,677.0 L 747.0,678.0 L 739.0,678.0 L 738.0,679.0 L 734.0,679.0 L 733.0,680.0 L 731.0,680.0 L 730.0,681.0 L 728.0,681.0 L 727.0,682.0 L 726.0,682.0 L 725.0,683.0 L 722.0,684.0 L 720.0,686.0 L 719.0,686.0 Z M 725.0,702.0 L 730.0,697.0 L 731.0,697.0 L 733.0,695.0 L 735.0,695.0 L 738.0,693.0 L 742.0,693.0 L 743.0,692.0 L 772.0,692.0 L 773.0,693.0 L 777.0,693.0 L 778.0,694.0 L 781.0,694.0 L 782.0,695.0 L 785.0,696.0 L 787.0,698.0 L 788.0,698.0 L 791.0,701.0 L 791.0,702.0 L 793.0,704.0 L 793.0,705.0 L 794.0,706.0 L 794.0,708.0 L 795.0,709.0 L 795.0,712.0 L 796.0,713.0 L 796.0,733.0 L 795.0,734.0 L 795.0,738.0 L 794.0,739.0 L 793.0,742.0 L 791.0,744.0 L 791.0,745.0 L 788.0,748.0 L 787.0,748.0 L 785.0,750.0 L 784.0,750.0 L 781.0,752.0 L 778.0,752.0 L 777.0,753.0 L 770.0,753.0 L 769.0,754.0 L 747.0,754.0 L 746.0,753.0 L 740.0,753.0 L 739.0,752.0 L 737.0,752.0 L 736.0,751.0 L 734.0,751.0 L 733.0,750.0 L 732.0,750.0 L 730.0,748.0 L 729.0,748.0 L 725.0,744.0 L 725.0,743.0 L 722.0,738.0 L 722.0,734.0 L 721.0,733.0 L 721.0,713.0 L 722.0,712.0 L 722.0,709.0 L 723.0,708.0 L 723.0,706.0 L 724.0,705.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 95.0,678.0 L 95.0,766.0 L 96.0,767.0 L 111.0,767.0 L 111.0,737.0 L 112.0,736.0 L 171.0,736.0 L 172.0,735.0 L 175.0,735.0 L 176.0,734.0 L 178.0,734.0 L 179.0,733.0 L 180.0,733.0 L 182.0,731.0 L 183.0,731.0 L 186.0,728.0 L 187.0,728.0 L 187.0,727.0 L 190.0,724.0 L 190.0,723.0 L 193.0,718.0 L 193.0,715.0 L 194.0,714.0 L 194.0,702.0 L 193.0,701.0 L 193.0,696.0 L 192.0,695.0 L 192.0,693.0 L 191.0,692.0 L 190.0,689.0 L 188.0,687.0 L 188.0,686.0 L 185.0,683.0 L 184.0,683.0 L 182.0,681.0 L 181.0,681.0 L 180.0,680.0 L 178.0,680.0 L 177.0,679.0 L 174.0,679.0 L 173.0,678.0 L 168.0,678.0 L 167.0,677.0 L 156.0,677.0 L 155.0,678.0 L 135.0,678.0 L 134.0,677.0 L 132.0,677.0 L 131.0,678.0 L 108.0,678.0 L 107.0,677.0 L 105.0,677.0 L 104.0,678.0 Z M 111.0,693.0 L 112.0,692.0 L 169.0,692.0 L 170.0,693.0 L 172.0,693.0 L 177.0,698.0 L 177.0,699.0 L 178.0,700.0 L 178.0,712.0 L 177.0,713.0 L 177.0,715.0 L 176.0,716.0 L 176.0,717.0 L 174.0,719.0 L 173.0,719.0 L 170.0,721.0 L 167.0,721.0 L 166.0,722.0 L 154.0,722.0 L 153.0,721.0 L 152.0,722.0 L 115.0,722.0 L 114.0,721.0 L 113.0,722.0 L 111.0,720.0 Z" fill="#FFFFFF" fill-rule="evenodd"/><rect x="250" y="678" width="16" height="90" fill="#FFFFFF"/></g>
|
||||||
|
<path d="M 1084.0,678.0 L 1063.0,678.0 L 1056.0,685.0 L 1056.0,686.0 L 1049.0,693.0 L 1049.0,694.0 L 1042.0,701.0 L 1042.0,702.0 L 1036.0,708.0 L 1036.0,709.0 L 1035.0,710.0 L 1035.0,711.0 L 1033.0,713.0 L 1033.0,714.0 L 1032.0,715.0 L 1032.0,716.0 L 1031.0,717.0 L 1031.0,718.0 L 1030.0,719.0 L 1030.0,720.0 L 1028.0,723.0 L 1028.0,725.0 L 1026.0,728.0 L 1026.0,730.0 L 1025.0,731.0 L 1025.0,733.0 L 1024.0,734.0 L 1024.0,738.0 L 1023.0,739.0 L 1023.0,743.0 L 1022.0,744.0 L 1022.0,752.0 L 1021.0,753.0 L 1021.0,766.0 L 1022.0,767.0 L 1038.0,767.0 L 1038.0,737.0 L 1039.0,736.0 L 1039.0,734.0 L 1040.0,733.0 L 1040.0,732.0 L 1041.0,731.0 L 1041.0,730.0 L 1043.0,728.0 L 1043.0,727.0 L 1046.0,724.0 L 1046.0,723.0 L 1052.0,717.0 L 1052.0,716.0 L 1060.0,708.0 L 1060.0,707.0 L 1065.0,702.0 L 1065.0,701.0 L 1072.0,694.0 L 1072.0,693.0 L 1077.0,688.0 L 1077.0,687.0 L 1082.0,682.0 L 1082.0,681.0 L 1084.0,679.0 L 1085.0,679.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 975.0,678.0 L 975.0,679.0 L 979.0,683.0 L 979.0,684.0 L 983.0,688.0 L 983.0,689.0 L 990.0,696.0 L 990.0,697.0 L 996.0,703.0 L 996.0,704.0 L 1002.0,710.0 L 1002.0,711.0 L 1010.0,719.0 L 1010.0,720.0 L 1014.0,724.0 L 1014.0,725.0 L 1017.0,728.0 L 1017.0,729.0 L 1018.0,730.0 L 1018.0,731.0 L 1019.0,732.0 L 1020.0,732.0 L 1020.0,729.0 L 1021.0,728.0 L 1021.0,726.0 L 1022.0,725.0 L 1022.0,722.0 L 1023.0,721.0 L 1023.0,715.0 L 1024.0,714.0 L 1023.0,713.0 L 1023.0,709.0 L 1022.0,708.0 L 1022.0,707.0 L 1021.0,706.0 L 1021.0,705.0 L 1016.0,700.0 L 1016.0,699.0 L 1010.0,693.0 L 1010.0,692.0 L 1003.0,685.0 L 1003.0,684.0 L 997.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="89 671 1268 103" role="img" aria-label="Petrolyst">
|
||||||
|
<!-- Petrolyst's wordmark alone: petrolyst-web/design/brand/02_Logo/Vector_Master/
|
||||||
|
petrolyst_wordmark_fullcolor.svg with the letters white for the dark chrome (PD-26); the Y keeps its original gradient. -->
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wordYGradient" x1="945" y1="660" x2="1090" y2="780" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#009688"/>
|
||||||
|
<stop offset="100%" stop-color="#00B6E6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g fill="#FFFFFF">
|
||||||
|
<path d="M 250.0,753.0 L 250.0,767.0 L 339.0,767.0 L 339.0,764.0 L 340.0,763.0 L 340.0,757.0 L 339.0,756.0 L 339.0,752.0 L 251.0,752.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 250.0,715.0 L 250.0,729.0 L 266.0,729.0 L 267.0,730.0 L 301.0,730.0 L 302.0,729.0 L 307.0,729.0 L 308.0,730.0 L 325.0,730.0 L 326.0,729.0 L 340.0,729.0 L 340.0,716.0 L 339.0,715.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 1260.0,678.0 L 1260.0,692.0 L 1264.0,692.0 L 1265.0,693.0 L 1296.0,693.0 L 1297.0,694.0 L 1297.0,767.0 L 1313.0,767.0 L 1313.0,694.0 L 1314.0,693.0 L 1351.0,693.0 L 1351.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 867.0,678.0 L 867.0,767.0 L 951.0,767.0 L 951.0,753.0 L 950.0,752.0 L 883.0,752.0 L 882.0,751.0 L 882.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 551.0,678.0 L 551.0,767.0 L 566.0,767.0 L 566.0,743.0 L 567.0,742.0 L 567.0,739.0 L 566.0,738.0 L 567.0,737.0 L 604.0,737.0 L 614.0,747.0 L 614.0,748.0 L 622.0,756.0 L 622.0,757.0 L 630.0,765.0 L 630.0,766.0 L 631.0,767.0 L 651.0,767.0 L 651.0,766.0 L 649.0,764.0 L 649.0,763.0 L 642.0,756.0 L 642.0,755.0 L 628.0,741.0 L 628.0,740.0 L 626.0,738.0 L 628.0,736.0 L 630.0,736.0 L 631.0,735.0 L 633.0,735.0 L 634.0,734.0 L 636.0,734.0 L 638.0,732.0 L 639.0,732.0 L 645.0,726.0 L 645.0,725.0 L 648.0,720.0 L 648.0,718.0 L 649.0,717.0 L 649.0,713.0 L 650.0,712.0 L 650.0,703.0 L 649.0,702.0 L 649.0,698.0 L 648.0,697.0 L 648.0,695.0 L 647.0,694.0 L 646.0,691.0 L 644.0,689.0 L 644.0,688.0 L 640.0,684.0 L 639.0,684.0 L 637.0,682.0 L 636.0,682.0 L 633.0,680.0 L 631.0,680.0 L 630.0,679.0 L 626.0,679.0 L 625.0,678.0 Z M 567.0,694.0 L 568.0,693.0 L 625.0,693.0 L 626.0,694.0 L 629.0,695.0 L 632.0,698.0 L 632.0,699.0 L 633.0,700.0 L 633.0,713.0 L 632.0,714.0 L 632.0,716.0 L 630.0,718.0 L 630.0,719.0 L 629.0,720.0 L 628.0,720.0 L 626.0,722.0 L 622.0,722.0 L 621.0,723.0 L 568.0,723.0 L 566.0,721.0 L 566.0,703.0 L 567.0,702.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 398.0,678.0 L 398.0,693.0 L 434.0,693.0 L 435.0,694.0 L 435.0,767.0 L 451.0,767.0 L 451.0,694.0 L 452.0,693.0 L 489.0,693.0 L 489.0,679.0 L 488.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 250.0,678.0 L 250.0,692.0 L 251.0,692.0 L 252.0,693.0 L 339.0,693.0 L 339.0,691.0 L 340.0,690.0 L 340.0,679.0 L 339.0,678.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 1134.0,683.0 L 1129.0,688.0 L 1129.0,689.0 L 1127.0,691.0 L 1127.0,693.0 L 1126.0,694.0 L 1126.0,696.0 L 1125.0,697.0 L 1125.0,712.0 L 1126.0,713.0 L 1126.0,715.0 L 1127.0,716.0 L 1128.0,719.0 L 1134.0,725.0 L 1135.0,725.0 L 1137.0,727.0 L 1139.0,727.0 L 1140.0,728.0 L 1143.0,728.0 L 1144.0,729.0 L 1185.0,729.0 L 1186.0,730.0 L 1191.0,730.0 L 1192.0,731.0 L 1193.0,731.0 L 1197.0,735.0 L 1197.0,739.0 L 1198.0,740.0 L 1198.0,742.0 L 1197.0,743.0 L 1197.0,746.0 L 1196.0,747.0 L 1196.0,748.0 L 1193.0,751.0 L 1192.0,751.0 L 1189.0,753.0 L 1184.0,753.0 L 1183.0,754.0 L 1151.0,754.0 L 1150.0,753.0 L 1143.0,753.0 L 1142.0,752.0 L 1139.0,752.0 L 1138.0,751.0 L 1135.0,751.0 L 1134.0,750.0 L 1131.0,750.0 L 1130.0,749.0 L 1128.0,749.0 L 1127.0,748.0 L 1125.0,748.0 L 1125.0,760.0 L 1124.0,761.0 L 1126.0,763.0 L 1128.0,763.0 L 1129.0,764.0 L 1131.0,764.0 L 1132.0,765.0 L 1134.0,765.0 L 1135.0,766.0 L 1139.0,766.0 L 1140.0,767.0 L 1146.0,767.0 L 1147.0,768.0 L 1186.0,768.0 L 1187.0,767.0 L 1191.0,767.0 L 1192.0,766.0 L 1195.0,766.0 L 1196.0,765.0 L 1198.0,765.0 L 1199.0,764.0 L 1201.0,764.0 L 1203.0,762.0 L 1204.0,762.0 L 1209.0,757.0 L 1209.0,756.0 L 1211.0,754.0 L 1211.0,753.0 L 1212.0,752.0 L 1212.0,750.0 L 1213.0,749.0 L 1213.0,734.0 L 1212.0,733.0 L 1212.0,730.0 L 1211.0,729.0 L 1211.0,728.0 L 1209.0,726.0 L 1209.0,725.0 L 1204.0,720.0 L 1203.0,720.0 L 1202.0,719.0 L 1201.0,719.0 L 1196.0,716.0 L 1192.0,716.0 L 1191.0,715.0 L 1148.0,715.0 L 1147.0,714.0 L 1145.0,714.0 L 1142.0,711.0 L 1142.0,710.0 L 1141.0,709.0 L 1141.0,699.0 L 1142.0,698.0 L 1142.0,697.0 L 1145.0,694.0 L 1146.0,694.0 L 1147.0,693.0 L 1150.0,693.0 L 1151.0,692.0 L 1190.0,692.0 L 1191.0,693.0 L 1196.0,693.0 L 1197.0,694.0 L 1201.0,694.0 L 1202.0,695.0 L 1204.0,695.0 L 1205.0,696.0 L 1209.0,696.0 L 1209.0,683.0 L 1206.0,681.0 L 1203.0,681.0 L 1202.0,680.0 L 1198.0,680.0 L 1197.0,679.0 L 1193.0,679.0 L 1192.0,678.0 L 1182.0,678.0 L 1181.0,677.0 L 1156.0,677.0 L 1155.0,678.0 L 1146.0,678.0 L 1145.0,679.0 L 1143.0,679.0 L 1142.0,680.0 L 1140.0,680.0 L 1139.0,681.0 L 1138.0,681.0 L 1137.0,682.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 714.0,691.0 L 714.0,692.0 L 711.0,695.0 L 711.0,696.0 L 709.0,698.0 L 709.0,699.0 L 708.0,700.0 L 708.0,702.0 L 707.0,703.0 L 707.0,706.0 L 706.0,707.0 L 706.0,711.0 L 705.0,712.0 L 705.0,734.0 L 706.0,735.0 L 706.0,739.0 L 707.0,740.0 L 707.0,743.0 L 708.0,744.0 L 708.0,745.0 L 709.0,746.0 L 709.0,747.0 L 710.0,748.0 L 711.0,751.0 L 713.0,753.0 L 713.0,754.0 L 718.0,759.0 L 719.0,759.0 L 721.0,761.0 L 722.0,761.0 L 723.0,762.0 L 724.0,762.0 L 729.0,765.0 L 731.0,765.0 L 732.0,766.0 L 735.0,766.0 L 736.0,767.0 L 741.0,767.0 L 742.0,768.0 L 775.0,768.0 L 776.0,767.0 L 781.0,767.0 L 782.0,766.0 L 785.0,766.0 L 786.0,765.0 L 788.0,765.0 L 789.0,764.0 L 790.0,764.0 L 791.0,763.0 L 792.0,763.0 L 793.0,762.0 L 796.0,761.0 L 798.0,759.0 L 799.0,759.0 L 805.0,753.0 L 805.0,752.0 L 807.0,750.0 L 807.0,749.0 L 809.0,746.0 L 809.0,744.0 L 811.0,741.0 L 811.0,737.0 L 812.0,736.0 L 812.0,711.0 L 811.0,710.0 L 811.0,706.0 L 810.0,705.0 L 810.0,703.0 L 809.0,702.0 L 809.0,700.0 L 808.0,699.0 L 808.0,698.0 L 806.0,696.0 L 806.0,695.0 L 803.0,692.0 L 803.0,691.0 L 799.0,687.0 L 798.0,687.0 L 793.0,683.0 L 792.0,683.0 L 791.0,682.0 L 789.0,682.0 L 786.0,680.0 L 783.0,680.0 L 782.0,679.0 L 779.0,679.0 L 778.0,678.0 L 770.0,678.0 L 769.0,677.0 L 748.0,677.0 L 747.0,678.0 L 739.0,678.0 L 738.0,679.0 L 734.0,679.0 L 733.0,680.0 L 731.0,680.0 L 730.0,681.0 L 728.0,681.0 L 727.0,682.0 L 726.0,682.0 L 725.0,683.0 L 722.0,684.0 L 720.0,686.0 L 719.0,686.0 Z M 725.0,702.0 L 730.0,697.0 L 731.0,697.0 L 733.0,695.0 L 735.0,695.0 L 738.0,693.0 L 742.0,693.0 L 743.0,692.0 L 772.0,692.0 L 773.0,693.0 L 777.0,693.0 L 778.0,694.0 L 781.0,694.0 L 782.0,695.0 L 785.0,696.0 L 787.0,698.0 L 788.0,698.0 L 791.0,701.0 L 791.0,702.0 L 793.0,704.0 L 793.0,705.0 L 794.0,706.0 L 794.0,708.0 L 795.0,709.0 L 795.0,712.0 L 796.0,713.0 L 796.0,733.0 L 795.0,734.0 L 795.0,738.0 L 794.0,739.0 L 793.0,742.0 L 791.0,744.0 L 791.0,745.0 L 788.0,748.0 L 787.0,748.0 L 785.0,750.0 L 784.0,750.0 L 781.0,752.0 L 778.0,752.0 L 777.0,753.0 L 770.0,753.0 L 769.0,754.0 L 747.0,754.0 L 746.0,753.0 L 740.0,753.0 L 739.0,752.0 L 737.0,752.0 L 736.0,751.0 L 734.0,751.0 L 733.0,750.0 L 732.0,750.0 L 730.0,748.0 L 729.0,748.0 L 725.0,744.0 L 725.0,743.0 L 722.0,738.0 L 722.0,734.0 L 721.0,733.0 L 721.0,713.0 L 722.0,712.0 L 722.0,709.0 L 723.0,708.0 L 723.0,706.0 L 724.0,705.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<path d="M 95.0,678.0 L 95.0,766.0 L 96.0,767.0 L 111.0,767.0 L 111.0,737.0 L 112.0,736.0 L 171.0,736.0 L 172.0,735.0 L 175.0,735.0 L 176.0,734.0 L 178.0,734.0 L 179.0,733.0 L 180.0,733.0 L 182.0,731.0 L 183.0,731.0 L 186.0,728.0 L 187.0,728.0 L 187.0,727.0 L 190.0,724.0 L 190.0,723.0 L 193.0,718.0 L 193.0,715.0 L 194.0,714.0 L 194.0,702.0 L 193.0,701.0 L 193.0,696.0 L 192.0,695.0 L 192.0,693.0 L 191.0,692.0 L 190.0,689.0 L 188.0,687.0 L 188.0,686.0 L 185.0,683.0 L 184.0,683.0 L 182.0,681.0 L 181.0,681.0 L 180.0,680.0 L 178.0,680.0 L 177.0,679.0 L 174.0,679.0 L 173.0,678.0 L 168.0,678.0 L 167.0,677.0 L 156.0,677.0 L 155.0,678.0 L 135.0,678.0 L 134.0,677.0 L 132.0,677.0 L 131.0,678.0 L 108.0,678.0 L 107.0,677.0 L 105.0,677.0 L 104.0,678.0 Z M 111.0,693.0 L 112.0,692.0 L 169.0,692.0 L 170.0,693.0 L 172.0,693.0 L 177.0,698.0 L 177.0,699.0 L 178.0,700.0 L 178.0,712.0 L 177.0,713.0 L 177.0,715.0 L 176.0,716.0 L 176.0,717.0 L 174.0,719.0 L 173.0,719.0 L 170.0,721.0 L 167.0,721.0 L 166.0,722.0 L 154.0,722.0 L 153.0,721.0 L 152.0,722.0 L 115.0,722.0 L 114.0,721.0 L 113.0,722.0 L 111.0,720.0 Z" fill="#FFFFFF" fill-rule="evenodd"/>
|
||||||
|
<rect x="250" y="678" width="16" height="90" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
<path d="M 1084.0,678.0 L 1063.0,678.0 L 1056.0,685.0 L 1056.0,686.0 L 1049.0,693.0 L 1049.0,694.0 L 1042.0,701.0 L 1042.0,702.0 L 1036.0,708.0 L 1036.0,709.0 L 1035.0,710.0 L 1035.0,711.0 L 1033.0,713.0 L 1033.0,714.0 L 1032.0,715.0 L 1032.0,716.0 L 1031.0,717.0 L 1031.0,718.0 L 1030.0,719.0 L 1030.0,720.0 L 1028.0,723.0 L 1028.0,725.0 L 1026.0,728.0 L 1026.0,730.0 L 1025.0,731.0 L 1025.0,733.0 L 1024.0,734.0 L 1024.0,738.0 L 1023.0,739.0 L 1023.0,743.0 L 1022.0,744.0 L 1022.0,752.0 L 1021.0,753.0 L 1021.0,766.0 L 1022.0,767.0 L 1038.0,767.0 L 1038.0,737.0 L 1039.0,736.0 L 1039.0,734.0 L 1040.0,733.0 L 1040.0,732.0 L 1041.0,731.0 L 1041.0,730.0 L 1043.0,728.0 L 1043.0,727.0 L 1046.0,724.0 L 1046.0,723.0 L 1052.0,717.0 L 1052.0,716.0 L 1060.0,708.0 L 1060.0,707.0 L 1065.0,702.0 L 1065.0,701.0 L 1072.0,694.0 L 1072.0,693.0 L 1077.0,688.0 L 1077.0,687.0 L 1082.0,682.0 L 1082.0,681.0 L 1084.0,679.0 L 1085.0,679.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
|
||||||
|
<path d="M 975.0,678.0 L 975.0,679.0 L 979.0,683.0 L 979.0,684.0 L 983.0,688.0 L 983.0,689.0 L 990.0,696.0 L 990.0,697.0 L 996.0,703.0 L 996.0,704.0 L 1002.0,710.0 L 1002.0,711.0 L 1010.0,719.0 L 1010.0,720.0 L 1014.0,724.0 L 1014.0,725.0 L 1017.0,728.0 L 1017.0,729.0 L 1018.0,730.0 L 1018.0,731.0 L 1019.0,732.0 L 1020.0,732.0 L 1020.0,729.0 L 1021.0,728.0 L 1021.0,726.0 L 1022.0,725.0 L 1022.0,722.0 L 1023.0,721.0 L 1023.0,715.0 L 1024.0,714.0 L 1023.0,713.0 L 1023.0,709.0 L 1022.0,708.0 L 1022.0,707.0 L 1021.0,706.0 L 1021.0,705.0 L 1016.0,700.0 L 1016.0,699.0 L 1010.0,693.0 L 1010.0,692.0 L 1003.0,685.0 L 1003.0,684.0 L 997.0,678.0 Z" fill="url(#wordYGradient)" fill-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -1,13 +1,45 @@
|
|||||||
# dashboards/
|
# dashboards/
|
||||||
|
|
||||||
Exported Superset dashboards and the operator scripts that import them. Not baked into the
|
Superset dashboards as code and the operator scripts that import them. Not baked into the
|
||||||
image, so changes here do not rebuild or restart Superset. Empty until the first fuel
|
image, so changes here do not rebuild or restart Superset.
|
||||||
dashboards exist.
|
|
||||||
|
- `atoma/`: ATOMA's two dashboards for the Petrolyst app (PD-12, PD-13; SPEC.md §10):
|
||||||
|
`overview` (fleet totals) and `site` (one site, its four sections as tabs), named `atoma-overview` and `atoma-site` until 30 Oct. The
|
||||||
|
app embeds them; admins open them in Superset, with the filter column. `SPEC.md` is the
|
||||||
|
design; `build_bundle.py` generates the import bundle and `import_bundle.py` imports it
|
||||||
|
(overwriting the objects the bundle defines).
|
||||||
|
|
||||||
|
Build and import:
|
||||||
|
|
||||||
|
docker cp atoma/import_bundle.py petrolyst-superset-1:/tmp/
|
||||||
|
docker exec petrolyst-superset-1 /app/.venv/bin/python /tmp/import_bundle.py --show-database
|
||||||
|
python3 atoma/build_bundle.py --database-uuid <printed uuid> --out atoma-dashboards.zip
|
||||||
|
docker cp atoma-dashboards.zip petrolyst-superset-1:/tmp/
|
||||||
|
docker exec petrolyst-superset-1 /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip --user <username>
|
||||||
|
|
||||||
|
Exit status 2 means a dataset's columns differ from its view: regenerate `atoma/columns.tsv`
|
||||||
|
(command in its header), rebuild and import again.
|
||||||
|
|
||||||
|
An import never deletes. Where they exist, delete these by hand after importing: the
|
||||||
|
dashboards from before 28 Oct ("ATOMA - Sites", "ATOMA - Site Detail") and their charts M1,
|
||||||
|
M3, M4 and M5; the ATOMA dashboard (slug `atoma`, retired 29 Oct with PD-13) and its chart D0
|
||||||
|
"Site"; the one-tab dashboards `atoma-site-summary`, `-fuel`, `-runtime` and `-baseline`
|
||||||
|
(replaced by `atoma-site` on 29 Oct); and the role "ATOMA Reader".
|
||||||
|
|
||||||
|
The dashboards get no role; the app shows them with guest passes (see "Embedding" in the
|
||||||
|
parent README; local stack only). Add `--embedded-out FILE --embed-domain <app origin>` to the
|
||||||
|
import to make them embeddable from the app's origin; FILE then receives
|
||||||
|
`{slug: embedding id}` for the Petrolyst server. The ids stay the same across imports. Local
|
||||||
|
stack:
|
||||||
|
|
||||||
|
docker exec fmdb-scratch-superset /app/.venv/bin/python /tmp/import_bundle.py /tmp/atoma-dashboards.zip \
|
||||||
|
--user admin --embedded-out /tmp/embedded-dashboards.json --embed-domain http://127.0.0.1:8098
|
||||||
|
docker cp fmdb-scratch-superset:/tmp/embedded-dashboards.json <Petrolyst>/.local/fmdb-scratch/
|
||||||
|
|
||||||
Export from a running instance (inside the container, as the server's deploy user):
|
Export from a running instance (inside the container, as the server's deploy user):
|
||||||
|
|
||||||
docker exec fuel-monitoring-superset-1 superset export-dashboards -f /tmp/dashboards.zip
|
docker exec petrolyst-superset-1 superset export-dashboards -f /tmp/dashboards.zip
|
||||||
docker cp fuel-monitoring-superset-1:/tmp/dashboards.zip .
|
docker cp petrolyst-superset-1:/tmp/dashboards.zip .
|
||||||
|
|
||||||
Import is the reverse with `superset import-dashboards -p <zip> -u <username>`. The
|
Import is the reverse with `superset import-dashboards -p <zip> -u <username>`. The
|
||||||
username must be an existing Superset user, which here means someone who has signed in
|
username must be an existing Superset user, which here means someone who has signed in
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
@@ -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())
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Keycloak sign-in for Superset at atoma.petrolyst.com.
|
"""Keycloak sign-in for Superset at atoma.petrolyst.com.
|
||||||
|
|
||||||
Set as CUSTOM_SECURITY_MANAGER in superset_config.py. Checked against
|
Set as CUSTOM_SECURITY_MANAGER in superset_config.py. Checked against
|
||||||
Flask-AppBuilder 5.0.2 as bundled in apache/superset:6.1.0. Three changes to
|
Flask-AppBuilder 5.0.2 as bundled in apache/superset:6.1.0. Four changes to
|
||||||
the stock behaviour:
|
the stock behaviour:
|
||||||
|
|
||||||
- User info and roles come from the ID token that Keycloak issues for client
|
- User info and roles come from the ID token that Keycloak issues for client
|
||||||
@@ -11,6 +11,8 @@ the stock behaviour:
|
|||||||
login page on /login/, which only shows a "Sign in with keycloak" button.
|
login page on /login/, which only shows a "Sign in with keycloak" button.
|
||||||
- /logout/ also ends the Keycloak session, so the next visit asks for the
|
- /logout/ also ends the Keycloak session, so the next visit asks for the
|
||||||
password and OTP again.
|
password and OTP again.
|
||||||
|
- A Keycloak user with no mapped Superset role is refused, and no account is
|
||||||
|
made. Customers' users belong in the Petrolyst app, never here (PD-21).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -70,6 +72,14 @@ class PetrolystSecurityManager(SupersetSecurityManager):
|
|||||||
# FAB's OAuth view, and that page wins the route.
|
# FAB's OAuth view, and that page wins the route.
|
||||||
register_superset_auth_view = False
|
register_superset_auth_view = False
|
||||||
|
|
||||||
|
def auth_user_oauth(self, userinfo: dict[str, Any]) -> Any:
|
||||||
|
# Only the mapped roles count: FAB's own calculation always adds the
|
||||||
|
# registration role, so it is never empty.
|
||||||
|
if not self.get_roles_from_keys(userinfo.get("role_keys", [])):
|
||||||
|
logger.warning("Keycloak sign-in refused: no mapped Superset role")
|
||||||
|
return None
|
||||||
|
return super().auth_user_oauth(userinfo)
|
||||||
|
|
||||||
def oauth_user_info(
|
def oauth_user_info(
|
||||||
self, provider: str, response: Optional[dict[str, Any]] = None
|
self, provider: str, response: Optional[dict[str, Any]] = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""The role "Petrolyst Guest": what the Petrolyst app's embedded dashboards may do (PD-12).
|
||||||
|
|
||||||
|
Superset gives every embedded viewer (a guest pass signed for one dashboard) the role named by
|
||||||
|
GUEST_ROLE_NAME. Which dashboard and which customer's rows a guest sees come from the pass, not from
|
||||||
|
the role. Superset
|
||||||
|
leaves the SQL out of chart-data responses for guests.
|
||||||
|
|
||||||
|
Embedding is switched on in the local stack only (.local/fmdb-scratch, superset_config_local.py),
|
||||||
|
so this script is not part of init.sh; run it by hand inside the Superset container, once, and
|
||||||
|
again whenever PERMISSIONS change:
|
||||||
|
|
||||||
|
docker exec <superset> /app/.venv/bin/python /app/petrolyst-roles/petrolyst_guest.py
|
||||||
|
|
||||||
|
Creates the role if needed and gives it exactly PERMISSIONS: missing ones are added, any other
|
||||||
|
is removed (also one added by hand in the UI). A second run changes nothing.
|
||||||
|
|
||||||
|
The list was established on the local stack by reading the ATOMA dashboard as a reader (the
|
||||||
|
former "ATOMA Reader" role, retired with PD-13): everything a dashboard asks the server for, and
|
||||||
|
nothing more. No menu access, nothing to write, no Explore, CSV, sharing, drill or query view.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROLE = "Petrolyst Guest"
|
||||||
|
PERMISSIONS = [
|
||||||
|
("can_read", "Dashboard"), # the dashboard and its charts' definitions
|
||||||
|
("can_dashboard", "Superset"), # the dashboard page
|
||||||
|
("can_dashboard_permalink", "Superset"), # a dashboard permalink
|
||||||
|
("can_read", "Chart"), # chart data
|
||||||
|
("can_time_range", "Api"), # the Viewing period filter
|
||||||
|
("can_read", "DashboardFilterStateRestApi"), # native filter state
|
||||||
|
("can_write", "DashboardFilterStateRestApi"),
|
||||||
|
("can_read", "DashboardPermalinkRestApi"),
|
||||||
|
("can_read", "CurrentUserRestApi"), # who is signed in
|
||||||
|
("can_read", "SecurityRestApi"), # the CSRF token
|
||||||
|
("can_get", "MenuApi"),
|
||||||
|
("can_log", "Superset"), # Superset's event log
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sync(name: str, permissions: list[tuple[str, str]]) -> int:
|
||||||
|
"""Give the role `name` exactly `permissions`, creating it if needed."""
|
||||||
|
from superset.app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
with app.app_context():
|
||||||
|
from superset import db, security_manager as sm
|
||||||
|
|
||||||
|
wanted = []
|
||||||
|
for permission, view in permissions:
|
||||||
|
pvm = sm.find_permission_view_menu(permission, view)
|
||||||
|
if pvm is None:
|
||||||
|
print(f"no permission {permission} on {view}: run `superset init` first?",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
wanted.append(pvm)
|
||||||
|
|
||||||
|
role = sm.find_role(name) or sm.add_role(name)
|
||||||
|
added = [pvm for pvm in wanted if pvm not in role.permissions]
|
||||||
|
removed = [pvm for pvm in role.permissions if pvm not in wanted]
|
||||||
|
for pvm in added:
|
||||||
|
sm.add_permission_role(role, pvm)
|
||||||
|
for pvm in removed:
|
||||||
|
sm.del_permission_role(role, pvm)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
print(f'role "{name}": {len(wanted)} permissions, {len(added)} added, '
|
||||||
|
f"{len(removed)} removed")
|
||||||
|
for pvm in removed:
|
||||||
|
print(f" removed {pvm.permission.name} on {pvm.view_menu.name}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
return sync(ROLE, PERMISSIONS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -45,11 +45,26 @@ SQLALCHEMY_DATABASE_URI = URL.create(
|
|||||||
database="superset",
|
database="superset",
|
||||||
).render_as_string(hide_password=False)
|
).render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
# --- The data: fuel-db ----------------------------------------------------------
|
||||||
|
# Superset reads fuel-db directly as the read-only role fuel_ro;
|
||||||
|
# superset-init registers that connection, "Fuel", without a password. Passwords of data
|
||||||
|
# connections come from secret files at connect time, so none is kept in Superset's own
|
||||||
|
# database. A connection added in the UI gets no password this way.
|
||||||
|
_DATABASE_PASSWORD_SECRETS = {"fuel_ro": "fuel_db_ro_password"}
|
||||||
|
|
||||||
|
|
||||||
|
def _database_password(url: URL) -> str | None:
|
||||||
|
secret = _DATABASE_PASSWORD_SECRETS.get(url.username or "")
|
||||||
|
return _read_secret(secret) if secret else None
|
||||||
|
|
||||||
|
|
||||||
|
SQLALCHEMY_CUSTOM_PASSWORD_STORE = _database_password
|
||||||
|
|
||||||
# --- Sign-in through Keycloak (client "petrolyst") ----------------------------
|
# --- Sign-in through Keycloak (client "petrolyst") ----------------------------
|
||||||
# Users and roles are managed in Keycloak; there is no local admin account.
|
# Users and roles are managed in Keycloak; there is no local admin account.
|
||||||
# Break-glass access: `superset fab ...` inside the container on the server.
|
# Break-glass access: `superset fab ...` inside the container on the server.
|
||||||
KEYCLOAK_REALM_URL = os.environ.get(
|
KEYCLOAK_REALM_URL = os.environ.get(
|
||||||
"KEYCLOAK_REALM_URL", "https://iam.thedigitalbridge.io/realms/thedigitalbridge"
|
"KEYCLOAK_REALM_URL", "https://iam.thedigitalbridge.io/realms/petrolyst"
|
||||||
).rstrip("/")
|
).rstrip("/")
|
||||||
|
|
||||||
AUTH_TYPE = AUTH_OAUTH
|
AUTH_TYPE = AUTH_OAUTH
|
||||||
@@ -79,15 +94,26 @@ AUTH_ROLES_MAPPING = {
|
|||||||
"superset-admin": ["Admin"],
|
"superset-admin": ["Admin"],
|
||||||
"superset-editor": ["Alpha"],
|
"superset-editor": ["Alpha"],
|
||||||
"superset-viewer": ["Gamma"],
|
"superset-viewer": ["Gamma"],
|
||||||
|
# Customers' users have a role of client "petrolyst-app", not of this one: refused here.
|
||||||
}
|
}
|
||||||
AUTH_ROLES_SYNC_AT_LOGIN = True
|
AUTH_ROLES_SYNC_AT_LOGIN = True
|
||||||
AUTH_USER_REGISTRATION = True
|
AUTH_USER_REGISTRATION = True
|
||||||
# Public has no permissions: a Keycloak user without a Superset role signs in
|
# Public has no permissions. A Keycloak user without a mapped role never gets
|
||||||
# and sees nothing.
|
# this far: PetrolystSecurityManager refuses the sign-in and makes no account.
|
||||||
AUTH_USER_REGISTRATION_ROLE = "Public"
|
AUTH_USER_REGISTRATION_ROLE = "Public"
|
||||||
|
|
||||||
# Viewers (Gamma) see a dashboard once it lists their role.
|
# Viewers (Gamma) see a dashboard once it lists their role.
|
||||||
FEATURE_FLAGS = {"DASHBOARD_RBAC": True}
|
# FILTERBAR_CLOSED_BY_DEFAULT: a dashboard's filter column opens collapsed, a
|
||||||
|
# narrow strip with an expand button (owner, 29 Oct); ?expand_filters=true
|
||||||
|
# opens it from a link.
|
||||||
|
FEATURE_FLAGS = {"DASHBOARD_RBAC": True, "FILTERBAR_CLOSED_BY_DEFAULT": True}
|
||||||
|
|
||||||
|
# deck.gl maps: OpenStreetMap raster tiles only. The default list adds Mapbox
|
||||||
|
# styles, which fail without MAPBOX_API_KEY. The default CSP already allows the
|
||||||
|
# tile host.
|
||||||
|
DECKGL_BASE_MAP = [
|
||||||
|
["tile://https://tile.openstreetmap.org/{z}/{x}/{y}.png", "OpenStreetMap"],
|
||||||
|
]
|
||||||
|
|
||||||
# --- Behind Traefik -----------------------------------------------------------
|
# --- Behind Traefik -----------------------------------------------------------
|
||||||
ENABLE_PROXY_FIX = True
|
ENABLE_PROXY_FIX = True
|
||||||
@@ -128,12 +154,12 @@ EXPLORE_FORM_DATA_CACHE_CONFIG = {
|
|||||||
RATELIMIT_STORAGE_URI = f"{REDIS_URL}/1"
|
RATELIMIT_STORAGE_URI = f"{REDIS_URL}/1"
|
||||||
|
|
||||||
# --- Petrolyst look and feel --------------------------------------------------
|
# --- Petrolyst look and feel --------------------------------------------------
|
||||||
# Values from petrolyst-ui/design/brand/07_UI_Tokens/petrolyst_tokens.css.
|
# Values from petrolyst-web/design/brand/07_UI_Tokens/petrolyst_tokens.css.
|
||||||
# Assets live in assets/branding/ and are served from /static/assets/branding/.
|
# Assets live in assets/branding/ and are served from /static/assets/branding/.
|
||||||
BRANDING = "/static/assets/branding"
|
BRANDING = "/static/assets/branding"
|
||||||
|
|
||||||
APP_NAME = "Petrolyst"
|
APP_NAME = "Petrolyst"
|
||||||
APP_ICON = f"{BRANDING}/petrolyst_logo_standard_fullcolor_no_tagline.svg"
|
APP_ICON = f"{BRANDING}/petrolyst_wordmark_fullcolor.svg"
|
||||||
FAVICONS = [
|
FAVICONS = [
|
||||||
{"href": f"{BRANDING}/favicon.svg", "type": "image/svg+xml"},
|
{"href": f"{BRANDING}/favicon.svg", "type": "image/svg+xml"},
|
||||||
{"href": f"{BRANDING}/favicon.ico", "type": "image/x-icon"},
|
{"href": f"{BRANDING}/favicon.ico", "type": "image/x-icon"},
|
||||||
@@ -147,10 +173,13 @@ THEME_DEFAULT["token"].update(
|
|||||||
"brandLogoAlt": "Petrolyst",
|
"brandLogoAlt": "Petrolyst",
|
||||||
"brandLogoUrl": APP_ICON,
|
"brandLogoUrl": APP_ICON,
|
||||||
"brandLogoHref": "/",
|
"brandLogoHref": "/",
|
||||||
# The standard logo is stacked (mark over wordmark); 40px keeps the Y
|
# The wordmark alone (owner, 29 Oct): in the stacked logo, at 40px, its
|
||||||
# legible and, with the margin, the 60px navbar height.
|
# letters were about 5px high. Its own Y carries the brand accent. 24px
|
||||||
"brandLogoHeight": "40px",
|
# (letters about 20px) and the margin fill the 60px navbar height. On the
|
||||||
"brandLogoMargin": "10px 0",
|
# left, 15px more than the navbar's 16px: the P (1.4px inside the image)
|
||||||
|
# lines up with the dashboard's content at 32px.
|
||||||
|
"brandLogoHeight": "24px",
|
||||||
|
"brandLogoMargin": "18px 0 18px 15px",
|
||||||
# Action teal, not the raw brand teal #009688: the brand guide keeps
|
# Action teal, not the raw brand teal #009688: the brand guide keeps
|
||||||
# raw teal off small text and controls.
|
# raw teal off small text and controls.
|
||||||
"colorPrimary": "#00695C",
|
"colorPrimary": "#00695C",
|
||||||
@@ -160,8 +189,8 @@ THEME_DEFAULT["token"].update(
|
|||||||
"colorError": "#B3261E",
|
"colorError": "#B3261E",
|
||||||
"colorInfo": "#2563EB",
|
"colorInfo": "#2563EB",
|
||||||
"colorTextBase": "#0B1D33",
|
"colorTextBase": "#0B1D33",
|
||||||
"colorBgLayout": "#F8FAFC",
|
"colorBgLayout": "#F7F9FA",
|
||||||
"colorBorder": "#E2E8F0",
|
"colorBorder": "#DDE5E9",
|
||||||
"borderRadius": 8,
|
"borderRadius": 8,
|
||||||
"fontFamily": "Inter, system-ui, sans-serif",
|
"fontFamily": "Inter, system-ui, sans-serif",
|
||||||
}
|
}
|
||||||
@@ -171,7 +200,7 @@ THEME_DARK = deepcopy(THEME_DEFAULT)
|
|||||||
THEME_DARK["algorithm"] = "dark"
|
THEME_DARK["algorithm"] = "dark"
|
||||||
THEME_DARK["token"].update(
|
THEME_DARK["token"].update(
|
||||||
{
|
{
|
||||||
"brandLogoUrl": f"{BRANDING}/petrolyst_logo_standard_white_no_tagline.svg",
|
"brandLogoUrl": f"{BRANDING}/petrolyst_wordmark_white_y.svg",
|
||||||
"colorPrimary": "#35C5B6",
|
"colorPrimary": "#35C5B6",
|
||||||
"colorLink": "#35C5B6",
|
"colorLink": "#35C5B6",
|
||||||
"colorSuccess": "#35C5B6",
|
"colorSuccess": "#35C5B6",
|
||||||
@@ -179,10 +208,10 @@ THEME_DARK["token"].update(
|
|||||||
"colorError": "#FF6B6B",
|
"colorError": "#FF6B6B",
|
||||||
"colorInfo": "#60A5FA",
|
"colorInfo": "#60A5FA",
|
||||||
"colorTextBase": "#F8FAFC",
|
"colorTextBase": "#F8FAFC",
|
||||||
"colorBgBase": "#071425",
|
"colorBgBase": "#1F2A33",
|
||||||
"colorBgLayout": "#071425",
|
"colorBgLayout": "#1F2A33",
|
||||||
"colorBgContainer": "#12263D",
|
"colorBgContainer": "#2A3945",
|
||||||
"colorBorder": "#526D82",
|
"colorBorder": "#76828E",
|
||||||
"colorEditorSelection": superset_defaults.THEME_DARK["token"][
|
"colorEditorSelection": superset_defaults.THEME_DARK["token"][
|
||||||
"colorEditorSelection"
|
"colorEditorSelection"
|
||||||
],
|
],
|
||||||
@@ -190,7 +219,7 @@ THEME_DARK["token"].update(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Chart colours. Validated with the dataviz skill's validator on both chart
|
# Chart colours. Validated with the dataviz skill's validator on both chart
|
||||||
# surfaces (#FFFFFF light, #12263D dark): all slots in both lightness bands and
|
# surfaces (#FFFFFF light, #2A3945 dark, PD-26): all slots in both lightness bands and
|
||||||
# >= 3:1; worst adjacent CVD dE 13.7, normal-vision dE 26.2; the first three
|
# >= 3:1; worst adjacent CVD dE 13.7, normal-vision dE 26.2; the first three
|
||||||
# slots also pass all-pairs (scatter, maps). Brand teal leads; brand cyan
|
# slots also pass all-pairs (scatter, maps). Brand teal leads; brand cyan
|
||||||
# #00B6E6 is snapped to #0097C4 because it is too light for the dark surface
|
# #00B6E6 is snapped to #0097C4 because it is too light for the dark surface
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
# Fuel-monitoring analytics: Superset on atoma.petrolyst.com, reading the
|
# Fuel-monitoring analytics: Superset on atoma.petrolyst.com, reading the
|
||||||
# operational store (fuel-db) through Trino. Run only through deploy/deploy.sh
|
# operational store (fuel-db) directly as the read-only role fuel_ro. Run only through deploy/deploy.sh or
|
||||||
# or deploy/rollback.sh: they export SUPERSET_IMAGE_TAG and TRINO_CONFIG_HASH
|
# deploy/rollback.sh: they export SUPERSET_IMAGE_TAG from the release's
|
||||||
# from the release's ARTIFACTS file, and the :? guards below stop Compose from
|
# ARTIFACTS file, and the :? guard below stops Compose from running without it.
|
||||||
# running with either unset.
|
name: petrolyst
|
||||||
name: fuel-monitoring
|
|
||||||
|
|
||||||
x-superset-env: &superset-env
|
x-superset-env: &superset-env
|
||||||
KEYCLOAK_REALM_URL: ${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/thedigitalbridge}
|
KEYCLOAK_REALM_URL: ${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/petrolyst}
|
||||||
|
|
||||||
x-superset: &superset
|
x-superset: &superset
|
||||||
image: fuel-superset:${SUPERSET_IMAGE_TAG:?SUPERSET_IMAGE_TAG unset - run through deploy/deploy.sh}
|
image: fuel-superset:${SUPERSET_IMAGE_TAG:?SUPERSET_IMAGE_TAG unset - run through deploy/deploy.sh}
|
||||||
@@ -15,6 +14,8 @@ x-superset: &superset
|
|||||||
- superset_secret_key
|
- superset_secret_key
|
||||||
- superset_db_password
|
- superset_db_password
|
||||||
- keycloak_petrolyst_client_secret
|
- keycloak_petrolyst_client_secret
|
||||||
|
# fuel_ro's password, read at connect time (superset_config.py)
|
||||||
|
- fuel_db_ro_password
|
||||||
|
|
||||||
services:
|
services:
|
||||||
superset:
|
superset:
|
||||||
@@ -100,25 +101,6 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
trino:
|
|
||||||
image: trinodb/trino:484
|
|
||||||
env_file:
|
|
||||||
- ${PETROLYST_ROOT:-/opt/petrolyst}/env/fuel-monitoring.env
|
|
||||||
volumes:
|
|
||||||
- ./trino/etc:/etc/trino:ro
|
|
||||||
labels:
|
|
||||||
# Compose cannot see content changes in a bind mount; this label changes
|
|
||||||
# with trino/etc, so exactly a config change recreates the container.
|
|
||||||
petrolyst.config-hash: ${TRINO_CONFIG_HASH:?TRINO_CONFIG_HASH unset - run through deploy/deploy.sh}
|
|
||||||
depends_on:
|
|
||||||
fuel-db:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- backend
|
|
||||||
restart: unless-stopped
|
|
||||||
mem_limit: 3g
|
|
||||||
# The image's own healthcheck (/usr/lib/trino/bin/health-check) applies.
|
|
||||||
|
|
||||||
fuel-db:
|
fuel-db:
|
||||||
image: timescale/timescaledb:2.31.0-pg17
|
image: timescale/timescaledb:2.31.0-pg17
|
||||||
environment:
|
environment:
|
||||||
@@ -128,7 +110,7 @@ services:
|
|||||||
TS_TUNE_NUM_CPUS: "2"
|
TS_TUNE_NUM_CPUS: "2"
|
||||||
secrets:
|
secrets:
|
||||||
- fuel_db_password
|
- fuel_db_password
|
||||||
- fuel_db_trino_password
|
- fuel_db_ro_password
|
||||||
volumes:
|
volumes:
|
||||||
- fuel_db_data:/var/lib/postgresql/data
|
- fuel_db_data:/var/lib/postgresql/data
|
||||||
# A single file, not the folder: the image's own TimescaleDB init scripts
|
# A single file, not the folder: the image's own TimescaleDB init scripts
|
||||||
@@ -157,9 +139,14 @@ networks:
|
|||||||
# so fuel-db's loopback port works.
|
# so fuel-db's loopback port works.
|
||||||
fuel-db-tunnel: {}
|
fuel-db-tunnel: {}
|
||||||
|
|
||||||
|
# Created under the project's first name, fuel-monitoring; named explicitly so the
|
||||||
|
# data stayed when the project became petrolyst (29 Oct 2026). Compose warns once
|
||||||
|
# that they belong to another project; that is expected.
|
||||||
volumes:
|
volumes:
|
||||||
superset_db_data:
|
superset_db_data:
|
||||||
|
name: fuel-monitoring_superset_db_data
|
||||||
fuel_db_data:
|
fuel_db_data:
|
||||||
|
name: fuel-monitoring_fuel_db_data
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
superset_secret_key:
|
superset_secret_key:
|
||||||
@@ -168,7 +155,7 @@ secrets:
|
|||||||
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/superset_db_password
|
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/superset_db_password
|
||||||
fuel_db_password:
|
fuel_db_password:
|
||||||
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_password
|
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_password
|
||||||
fuel_db_trino_password:
|
fuel_db_ro_password:
|
||||||
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_trino_password
|
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/fuel-monitoring/fuel_db_ro_password
|
||||||
keycloak_petrolyst_client_secret:
|
keycloak_petrolyst_client_secret:
|
||||||
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/shared/keycloak_petrolyst_client_secret
|
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/shared/keycloak_petrolyst_realm_client_secret
|
||||||
@@ -5,21 +5,24 @@
|
|||||||
# Loaders log in as their own role but act as fuel_owner (README
|
# Loaders log in as their own role but act as fuel_owner (README
|
||||||
# "Data loaders"): the default privileges below cover only what
|
# "Data loaders"): the default privileges below cover only what
|
||||||
# fuel_owner itself creates, not what its members create.
|
# fuel_owner itself creates, not what its members create.
|
||||||
# trino_ro what Trino connects as: read-only, also on tables created later.
|
# fuel_ro what Superset and the Petrolyst server read as: SELECT on public,
|
||||||
|
# also on tables created later. Read-only by default as well: a
|
||||||
|
# second lock behind the grants.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
trino_password=$(cat /run/secrets/fuel_db_trino_password)
|
ro_password=$(cat /run/secrets/fuel_db_ro_password)
|
||||||
|
|
||||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
|
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
|
||||||
-v trino_password="$trino_password" <<'SQL'
|
-v ro_password="$ro_password" <<'SQL'
|
||||||
CREATE ROLE fuel_owner NOLOGIN;
|
CREATE ROLE fuel_owner NOLOGIN;
|
||||||
ALTER DATABASE fuel OWNER TO fuel_owner;
|
ALTER DATABASE fuel OWNER TO fuel_owner;
|
||||||
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
|
||||||
|
|
||||||
CREATE ROLE trino_ro LOGIN PASSWORD :'trino_password';
|
CREATE ROLE fuel_ro LOGIN PASSWORD :'ro_password';
|
||||||
GRANT CONNECT ON DATABASE fuel TO trino_ro;
|
ALTER ROLE fuel_ro SET default_transaction_read_only = on;
|
||||||
GRANT USAGE ON SCHEMA public TO trino_ro;
|
GRANT CONNECT ON DATABASE fuel TO fuel_ro;
|
||||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO trino_ro;
|
GRANT USAGE ON SCHEMA public TO fuel_ro;
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO fuel_ro;
|
||||||
ALTER DEFAULT PRIVILEGES FOR ROLE fuel_owner IN SCHEMA public
|
ALTER DEFAULT PRIVILEGES FOR ROLE fuel_owner IN SCHEMA public
|
||||||
GRANT SELECT ON TABLES TO trino_ro;
|
GRANT SELECT ON TABLES TO fuel_ro;
|
||||||
SQL
|
SQL
|
||||||
@@ -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 $$;
|
||||||
@@ -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: //'
|
||||||
@@ -6,12 +6,12 @@
|
|||||||
FROM apache/superset:6.1.0@sha256:16b50bbef6648912a79e3293d418fefa743ce555d36c5af6b85d859119ed7f88
|
FROM apache/superset:6.1.0@sha256:16b50bbef6648912a79e3293d418fefa743ce555d36c5af6b85d859119ed7f88
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
# The 6.1.0 image ships neither an OAuth client nor the Postgres and Trino
|
# The 6.1.0 image ships neither an OAuth client nor the Postgres driver.
|
||||||
# drivers.
|
|
||||||
RUN uv pip install --no-cache --python /app/.venv/bin/python \
|
RUN uv pip install --no-cache --python /app/.venv/bin/python \
|
||||||
authlib==1.9.1 psycopg2-binary==2.9.13 "trino[sqlalchemy]==0.340.0"
|
authlib==1.9.1 psycopg2-binary==2.9.13
|
||||||
|
|
||||||
COPY --from=insights --chown=superset:superset superset_config.py petrolyst_security.py /app/pythonpath/
|
COPY --from=insights --chown=superset:superset superset_config.py petrolyst_security.py /app/pythonpath/
|
||||||
|
COPY --from=insights --chown=superset:superset roles/ /app/petrolyst-roles/
|
||||||
COPY --from=insights --chown=superset:superset assets/branding/ /app/superset/static/assets/branding/
|
COPY --from=insights --chown=superset:superset assets/branding/ /app/superset/static/assets/branding/
|
||||||
COPY --from=insights patches/ /app/patches/petrolyst/
|
COPY --from=insights patches/ /app/patches/petrolyst/
|
||||||
# Stop at the first patch or verify that fails, so a later success cannot hide
|
# Stop at the first patch or verify that fails, so a later success cannot hide
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# superset-init: migrate the metadata database, sync roles and permissions, and
|
# superset-init: migrate the metadata database, sync roles and permissions, and
|
||||||
# register the Trino connection. It is copied into the Superset image, so a
|
# register the connection to fuel-db. It is copied into the Superset image, so a
|
||||||
# change here changes the image tag; deploy/deploy.sh runs it on a fresh install
|
# change here changes the image tag; deploy/deploy.sh runs it on a fresh install
|
||||||
# and whenever that tag changes. Every step is idempotent. Unlike the
|
# and whenever that tag changes. Every step is idempotent. Unlike the
|
||||||
# upstream docker-init.sh it never creates a local admin: users come from
|
# upstream docker-init.sh it never creates a local admin: users come from
|
||||||
@@ -9,6 +9,8 @@ set -euo pipefail
|
|||||||
|
|
||||||
superset db upgrade
|
superset db upgrade
|
||||||
superset init
|
superset init
|
||||||
|
|
||||||
|
# No password here: superset_config.py reads it from /run/secrets at connect time.
|
||||||
superset set-database-uri \
|
superset set-database-uri \
|
||||||
--database_name "Fuel (Trino)" \
|
--database_name "Fuel" \
|
||||||
--uri "trino://superset@trino:8080/fuel"
|
--uri "postgresql+psycopg2://fuel_ro@fuel-db:5432/fuel"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Analytics never writes through Trino. fuel-db's trino_ro role is read-only as
|
|
||||||
# well; this is the second lock.
|
|
||||||
access-control.name=read-only
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# The fuel-monitoring operational store (service fuel-db), read-only.
|
|
||||||
connector.name=postgresql
|
|
||||||
connection-url=jdbc:postgresql://fuel-db:5432/fuel
|
|
||||||
connection-user=trino_ro
|
|
||||||
connection-password=${ENV:FUEL_DB_TRINO_PASSWORD}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Single node: coordinator and worker in one process. Reachable only on the
|
|
||||||
# backend network (Superset); no port is published and Traefik has no route.
|
|
||||||
coordinator=true
|
|
||||||
node-scheduler.include-coordinator=true
|
|
||||||
http-server.http.port=8080
|
|
||||||
discovery.uri=http://localhost:8080
|
|
||||||
catalog.management=static
|
|
||||||
|
|
||||||
# Memory bounds. The container is capped at 3g (compose.yaml) and jvm.config
|
|
||||||
# gives the heap 75 % of it (~2.25 GB); 1 GB for queries plus 512 MB headroom
|
|
||||||
# leaves room for the rest of the heap.
|
|
||||||
query.max-memory=1GB
|
|
||||||
query.max-memory-per-node=1GB
|
|
||||||
memory.heap-headroom-per-node=512MB
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-server
|
|
||||||
-agentpath:/usr/lib/trino/bin/libjvmkill.so
|
|
||||||
-XX:InitialRAMPercentage=75
|
|
||||||
-XX:MaxRAMPercentage=75
|
|
||||||
-XX:G1HeapRegionSize=32M
|
|
||||||
-XX:+ExplicitGCInvokesConcurrent
|
|
||||||
-XX:+HeapDumpOnOutOfMemoryError
|
|
||||||
-XX:+ExitOnOutOfMemoryError
|
|
||||||
-XX:-OmitStackTraceInFastThrow
|
|
||||||
-XX:ReservedCodeCacheSize=256M
|
|
||||||
-XX:PerMethodRecompilationCutoff=10000
|
|
||||||
-XX:PerBytecodeRecompilationCutoff=10000
|
|
||||||
-Djdk.attach.allowAttachSelf=true
|
|
||||||
-Djdk.nio.maxCachedBufferSize=2000000
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
io.trino=INFO
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
node.environment=petrolyst
|
|
||||||
node.data-dir=/data/trino
|
|
||||||
node.id=${ENV:HOSTNAME}
|
|
||||||
@@ -69,7 +69,7 @@ services:
|
|||||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5
|
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5
|
||||||
command:
|
command:
|
||||||
- --provider=keycloak-oidc
|
- --provider=keycloak-oidc
|
||||||
- --oidc-issuer-url=${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/thedigitalbridge}
|
- --oidc-issuer-url=${KEYCLOAK_REALM_URL:-https://iam.thedigitalbridge.io/realms/petrolyst}
|
||||||
- --client-id=petrolyst
|
- --client-id=petrolyst
|
||||||
- --client-secret-file=/run/secrets/keycloak_petrolyst_client_secret
|
- --client-secret-file=/run/secrets/keycloak_petrolyst_client_secret
|
||||||
- --cookie-secret-file=/run/secrets/oauth2_proxy_cookie_secret
|
- --cookie-secret-file=/run/secrets/oauth2_proxy_cookie_secret
|
||||||
@@ -114,6 +114,6 @@ volumes:
|
|||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
keycloak_petrolyst_client_secret:
|
keycloak_petrolyst_client_secret:
|
||||||
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/shared/keycloak_petrolyst_client_secret
|
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/shared/keycloak_petrolyst_realm_client_secret
|
||||||
oauth2_proxy_cookie_secret:
|
oauth2_proxy_cookie_secret:
|
||||||
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/traefik/oauth2_proxy_cookie_secret
|
file: ${PETROLYST_ROOT:-/opt/petrolyst}/secrets/traefik/oauth2_proxy_cookie_secret
|
||||||