From 6fa33d86859683f6ff26627bd290e653afdc9786 Mon Sep 17 00:00:00 2001 From: mtinti Date: Wed, 1 Jul 2026 15:13:03 +0100 Subject: [PATCH 01/16] Add RdmpCohortBuildHealthBoardBreakdown plugin package (9.2.3) Self-contained package for the cohort-build health-board breakdown plugin: the built .rdmp, install/usage notes, plugin source, and design + technical documentation. Splits the Cohort Builder's per-step count tree by Scottish health board (wide CSV: Metric, Total, per-board columns, Other, NotKnown, and a % of final cohort row). Cache-only recompose, validated against a deterministic synthetic fixture. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0169JCnaL3fhhZjseDx2XXT2 --- .../INSTALL.md | 56 +++ RdmpCohortBuildHealthBoardBreakdown/README.md | 48 +++ .../RdmpCohortBuildHealthBoardBreakdown.rdmp | Bin 0 -> 15537 bytes .../docs/BUILD-BREAKDOWN-FEASIBILITY.md | 179 ++++++++++ .../docs/BUILD-BREAKDOWN-PLAN.md | 145 ++++++++ .../docs/BUILD-BREAKDOWN-TEST-FIXTURE.md | 87 +++++ .../docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md | 94 +++++ .../docs/TECHNICAL-BACKGROUND.html | 230 ++++++++++++ ...HealthBoardBreakdownPluginUserInterface.cs | 24 ++ .../CohortBuildHealthBoardBreakdownReport.cs | 174 ++++++++++ ...ndExportCohortBuildHealthBoardBreakdown.cs | 327 ++++++++++++++++++ .../src/HealthBoardLookup.cs | 61 ++++ ...RdmpCohortBuildHealthBoardBreakdown.csproj | 23 ++ ...RdmpCohortBuildHealthBoardBreakdown.nuspec | 17 + 14 files changed, 1465 insertions(+) create mode 100644 RdmpCohortBuildHealthBoardBreakdown/INSTALL.md create mode 100644 RdmpCohortBuildHealthBoardBreakdown/README.md create mode 100644 RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp create mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-FEASIBILITY.md create mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-PLAN.md create mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-TEST-FIXTURE.md create mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md create mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/TECHNICAL-BACKGROUND.html create mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownPluginUserInterface.cs create mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs create mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs create mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/HealthBoardLookup.cs create mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.csproj create mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.nuspec diff --git a/RdmpCohortBuildHealthBoardBreakdown/INSTALL.md b/RdmpCohortBuildHealthBoardBreakdown/INSTALL.md new file mode 100644 index 0000000000..780967f951 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/INSTALL.md @@ -0,0 +1,56 @@ +# RdmpCohortBuildHealthBoardBreakdown plugin (RDMP 9.2.3) + +Reproduces the Cohort Builder's per-set / per-container count tree (the FinalCount and cumulative +running totals shown as UNION/INTERSECT/EXCEPT are applied) **split by Scottish health board**, plus +an unfiltered baseline. Saved as a long-format CSV. + +Built against the **released RDMP 9.2.3**. Do not use on a different major.minor RDMP. + +## How it works (cache-only, cross-server safe) + +It builds the cohort **once** (populating the query cache), then recomposes every count point from the +cached per-set identifier tables and splits each by `SHARE_Demography.Region` with one GROUP BY per +node. It never re-runs the source catalogues per board, and never touches the source servers after the +single build — only the query-cache server (which is why the demography catalogue must be on the same +server as the query cache). + +## Requirements + +- The cohort identification configuration must have a **query caching server** configured (the + breakdown works only on cached results; it refuses otherwise). +- `SHARE_Demography` (with a `Region` health-board cipher column and a CHI IsExtractionIdentifier + column) must be on the **same SQL server as the query cache** (the command checks and refuses if not). + +## Install + +**GUI:** RDMP desktop → Plugins node → *Add Plugin* (or drag `RdmpCohortBuildHealthBoardBreakdown.rdmp` +onto it) → restart RDMP. **Or** drop the `.rdmp` next to `rdmp.exe` / +`ResearchDataManagementPlatform.exe`. + +Confirm (CLI): `rdmp.exe cmd ListSupportedCommands` lists `ExportCohortBuildHealthBoardBreakdown`. + +## Use + +**GUI:** right-click a Cohort Identification Configuration → *Export ... Build Health Board Breakdown* +→ choose a CSV path. + +**CLI:** +``` +rdmp.exe cmd ExportCohortBuildHealthBoardBreakdown CohortIdentificationConfiguration: out.csv "SHARE_Demography" "Region" +``` +Args after the CIC are optional (defaults: `-build-healthboard.csv`, `SHARE_Demography`, `Region`). + +## Output (long format, board-grouped) + +Columns: `Board, Node, Order, Type, Name, Container, SetOperation, FinalCount, CumulativeCount`. The +`Unfiltered` tree first (RDMP's own numbers), then each health board's full tree (boards partition the +cohort, 1 patient ↔ 1 board), then an `Unknown` board (patients with no / unmapped region). `FinalCount` +is the node's own count; `CumulativeCount` is the running total within the parent container (blank for +the first child, as in the UI). Boards (+ Unknown) reconcile to the unfiltered total at every node. + +## Validation + +Verified end-to-end on a deterministic synthetic fixture (top EXCEPT over an inclusion INTERSECT minus +four exclusion sets, cohort partitioned across 3 boards): every national and per-board FinalCount and +cumulative was asserted cell-by-cell, the unfiltered column equals RDMP's own CohortCompiler counts, and +the boards sum to national at every node. diff --git a/RdmpCohortBuildHealthBoardBreakdown/README.md b/RdmpCohortBuildHealthBoardBreakdown/README.md new file mode 100644 index 0000000000..8d860aad02 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/README.md @@ -0,0 +1,48 @@ +# RdmpCohortBuildHealthBoardBreakdown (RDMP 9.2.3 plugin) + +Reproduces the Cohort Builder's per-set / per-container count tree (the `FinalCount` and cumulative +running totals shown as UNION/INTERSECT/EXCEPT are applied) **split by Scottish health board**, plus an +unfiltered national total, and writes it to a wide CSV. + +This folder is a self-contained package: the ready-to-install plugin, install/usage notes, the source, +and the design + technical documentation. + +## Contents + +| Path | What it is | +|---|---| +| `RdmpCohortBuildHealthBoardBreakdown.rdmp` | the built plugin (drop into RDMP / add via the Plugins node) | +| `INSTALL.md` | install + usage (GUI right-click and CLI) | +| `src/` | plugin source (command, report, health-board lookup, UI hook, csproj, nuspec) | +| `docs/TECHNICAL-BACKGROUND.html` | high-level walkthrough — what runs at each step, real code + SQL | +| `docs/BUILD-BREAKDOWN-FEASIBILITY.md` | feasibility + the distributivity / cache-only rationale | +| `docs/BUILD-BREAKDOWN-PLAN.md` | implementation plan (as built) | +| `docs/BUILD-BREAKDOWN-TEST-FIXTURE.md` | the deterministic synthetic validation fixture | +| `docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md` | the wide-report layout decisions | + +## How it works (in one paragraph) + +It builds the national cohort once (which populates RDMP's query cache), then recomposes every count +point purely from the cached per-set identifier tables and splits each by `SHARE_Demography.Region` +with one `GROUP BY` per node — all boards at once. No per-board rebuild, and no hits on the source +catalogues after the single build (so it is cross-server safe). Requires a query-caching server, and +the demography catalogue on the same server as the cache. + +## Output + +Wide CSV: one row per count point (name once), a `Metric` column (Final + Cumulative), a `Total` +column (RDMP's national number), one column per Scottish board, then `Other` (present non-Scottish / +unmapped region codes) and `NotKnown` (not in demography / null region). The column header is repeated +above a `% of final cohort` row. Boards + Other + NotKnown reconcile to Total on every row. + +## Validation + +Verified end-to-end against a deterministic synthetic fixture (top EXCEPT over an inclusion INTERSECT +minus four exclusion sets, the cohort partitioned across 3 boards): every national and per-board +`FinalCount` / cumulative is asserted cell-by-cell, the unfiltered column equals RDMP's own +`CohortCompiler` counts, and the boards sum to national at every node. See `docs/BUILD-BREAKDOWN-TEST-FIXTURE.md`. + +## Build from source (optional) + +`src/` builds against RDMP 9.2.3 (`Rdmp.Core`, `Private=false`). Package the resulting DLL + nuspec into +a `.rdmp` zip (`` at root, DLL under `lib/net10.0/`). diff --git a/RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp b/RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp new file mode 100644 index 0000000000000000000000000000000000000000..8226c6908e7058ba93d7f2ab66cef33aa451cbcf GIT binary patch literal 15537 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 ts}}z!FZrK}|Ek5)e^dOwsms67lmAm` in the query-cache + DB; patient-index tables → `JoinableInceptionQuery_...`. Fetch via + `CachedAggregateConfigurationResultsManager.GetLatestResultsTable(agg, IndexedExtractionIdentifierList, sql)` + → fully-qualified table name. (The set's cached list is its FINAL list — post-filters, post-PIT-join.) +- **NOT cached:** container totals, cumulative/running totals. `AggregationContainerTask` is not a + `CacheableTask`; cumulative is computed by a throwaway `CohortQueryBuilder` over the parent + container with `StopContainerWhenYouReach = childK`, run only to count rows in memory, then discarded. +- Counts in the UI are `DataTable.Rows.Count` of the pulled identifier list (not SQL `COUNT`). + `FinalRowCount` = the node's own count; `CumulativeRowCount` = running total within its container + (null for the first child / when cumulative totals were off). + +**Consequence:** the cache gives us exactly the per-set identifier tables. Container/cumulative points +must be *recomposed* — but RDMP will generate that composition SQL for us (reading from cache), or we +recompose in memory. Either way the expensive source queries run once (the baseline build). + +## 3. Count points to reproduce (mirror the UI exactly) + +Walk `CohortAggregateContainer.GetOrderedContents()` recursively (respect `Order`, `Operation`, +skip disabled), and for each container emit: +- one **set total** row per child set (`FinalRowCount`), +- one **cumulative** row per non-first child (`CumulativeRowCount` = container up to & incl. child k), +- one **container total** row. + +This is the same enumeration the existing `CohortCountReport` produces; we reuse its row shape and add +a board dimension. + +## 4. Two implementations (both cache-leveraged; recommend A) + +### A. Server-side recompose via RDMP's own query builder (recommended) +For each count point, ask RDMP for its identifier-list SQL — it already splices in the cache tables: +- set total → `CohortQueryBuilder(aggregate, globals, childProvider)` +- container total → `CohortQueryBuilder(container, globals, childProvider)` +- cumulative k → `CohortQueryBuilder(parentContainer, …){ StopContainerWhenYouReach = childK }` + +Then wrap (params hoisted exactly like the committed-cohort command already does): +```sql +SELECT d.Region, COUNT(DISTINCT i.id) AS n +FROM ( ) i +JOIN SHARE_Demography d ON d.chi = i.id +GROUP BY d.Region +``` +- **One query per count point, all boards at once.** ~`2·sets + containers` queries total (tens, not + hundreds) — independent of board count. +- **Fidelity:** uses RDMP's exact composition SQL, so it can't drift from the UI semantics + (order/EXCEPT/disabled/PITs all handled by RDMP). +- **New code is small:** tree walk + the `GROUP BY Region` wrapper + assembling the matrix. +- **Requirement:** the query-cache DB and `SHARE_Demography` must be co-queryable (same server, or + 3-part/linked). Needs confirming (the final-list feature already assumes same server for demography). + +### B. Client-side recompose (fallback / no cross-server) +Fetch each set's identifiers once with Region attached (`SELECT t.id, d.Region FROM t JOIN +SHARE_Demography d …`), then replay the container set-algebra in memory per board (hash sets), mirroring +`CohortCompiler`. Produces every total + cumulative for every board and the unfiltered baseline in one +pass, **no cross-server join**. Cost: pulls all set identifiers to the client (heavy for very large +cohorts). Good fallback when cache and demography live on different servers. + +> Recommendation: **A** for fidelity + scale; keep **B** as the fallback when cache/demography aren't +> co-located. Both avoid the N-board rebuild. + +## 5. Build-once + cache + +1. Ensure the CIC has a `QueryCachingServer` and run `CohortCompilerRunner` once with + `IncludeCumulativeTotals = true`. This (a) populates every per-set cache table and (b) gives the + **baseline** `FinalRowCount`/`CumulativeRowCount` per node straight from RDMP. +2. If the cache is already fresh (user built it in the UI), step 1 is a no-op fast path — we can read + the cache tables directly without re-running source queries. +3. All per-board work in §4 then reads only the cache (+ demography), never the source databases. + +## 6. Built-in correctness check + +The **unfiltered** column must equal RDMP's own `FinalRowCount`/`CumulativeRowCount` from the baseline +build, and the per-board counts (+ an `Unknown`/not-in-demography bucket) must **sum to the unfiltered** +at every node. Both are cheap asserts that catch any composition/order mistake automatically. + +## 7. Output options (for discussion) + +Rows = count points in tree order (Order, Type, Name, Container, SetOperation). Then either: +- **Long:** add `Board`, `Node`, `FinalCount`, `CumulativeCount` columns (one row per count-point × + board). Most flexible; easy to pivot. ← suggested default. +- **Wide:** a `FinalCount`/`CumulativeCount` pair of columns per board. Closest to "the UI table with a + column per board" but wide and awkward with ~15 boards × 2. +- **One file per board** (+ an `_unfiltered` file): each is exactly today's `CohortCountReport` CSV. + +All reuse `HealthBoardLookup` (Region→board/node) and the `Unknown` bucket from the existing feature. + +## 8. Scope / caveats + +- **CIC-only.** This needs the build tree; a committed `ExtractableCohort` has no tree (final-list + breakdown already covers that case). +- Ships as a second command in the existing `RdmpHealthBoardBreakdown` plugin (e.g. + `ExportCohortBuildHealthBoardBreakdown`), reusing the demography resolution, param-hoisting, CSV and + `HealthBoardLookup` already written. +- Cross-server (approach A) and cache-presence are the two real requirements — both checkable up front + with a clear `SetImpossible` message. +- Disabled sets/containers and patient-index tables: handled for free in A (RDMP's SQL); must be + replicated in B. + +## 9. Effort (rough) + +- Tree walk + count-point enumeration (reuse `CohortCountReport` shape): ~0.5 day +- Approach A wrapper + param hoist + run/collect + matrix assembly: ~1 day +- Baseline build + reconciliation asserts: ~0.5 day +- No-DB unit tests (composition/ordering/Unknown) + a docker DB end-to-end (small CIC with a cache, + EXCEPT over INTERSECT, assert unfiltered == RDMP and boards sum to total): ~1 day +- Approach B fallback (optional): ~1 day +- Plugin wiring + 9.2.3 build (pipeline already exists): ~0.5 day + +**≈ 3–3.5 days** (A only), +1 day for the B fallback. + +## 10. Decisions (locked 2026-06-25) + +1. **Approach A only** (server-side recompose via `CohortQueryBuilder`, reading the cache). No client-side + B fallback — the cohort spans servers, so the cache is the single consolidation point and we operate + purely on it. +2. **New, separate plugin** (`RdmpCohortBuildHealthBoardBreakdown`), not an addition to the existing + final-list plugin. Reuses `HealthBoardLookup` + the demography-resolution / param-hoist / CSV patterns + by copying them in (plugin must be self-contained for the 9.2.3 build). +3. **Output = long format, grouped clearly by health board.** One row per (board × count-point), columns: + `Board, Node, Order, Type, Name, Container, SetOperation, FinalCount, CumulativeCount`. Rows ordered + board-major (all of board T's tree, then board G's, …), with an `Unfiltered` pseudo-board first and an + `Unknown` board last so each node reconciles. +4. **Cache is REQUIRED.** Catalogues are on different servers, so without a populated query cache the + composition can't run. `SetImpossible` if the CIC has no `QueryCachingServer` or the per-set caches + are missing/stale (offer to run one baseline build to populate). +5. **Cross-server demography:** the recompose + `GROUP BY Region` join runs on the **cache server**, so + `SHARE_Demography` must be reachable from there. The plugin checks `QueryCachingServer.Server` == + `SHARE_Demography` `TableInfo.Server` at construction and `SetImpossible`s with a clear message if not. + (Likely same server, but verified at runtime — see note below.) +6. **Cumulative semantics:** reproduce RDMP's "cumulative within container, from the 2nd child" exactly, + by using RDMP's own `StopContainerWhenYouReach` query (guarantees parity with the UI). +7. Boards come only from `SHARE_Demography.Region` (no per-board published filters needed for counting). + +> **Co-location note:** the first (final-list) plugin proved `SHARE_Demography` is on the same server as +> the cohort/data store; it did NOT exercise the query-cache server (a separate `ExternalDatabaseServer`). +> So co-location of cache + demography is *probable* but not proven from that work — hence the runtime +> check in decision 5. If they turn out to be on different servers, the mitigation is to materialise a +> small `(chi, Region)` tag table on the cache server once per run and join to that instead (out of scope +> unless the check fails). diff --git a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-PLAN.md b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-PLAN.md new file mode 100644 index 0000000000..5f8b15195a --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-PLAN.md @@ -0,0 +1,145 @@ +# Implementation plan — per-health-board cohort *build* breakdown (cache-only, approach A) + +Companion to `BUILD-BREAKDOWN-FEASIBILITY.md` (decisions locked in §10 there). Reproduces the Cohort +Builder's per-set / per-container *total* + *cumulative* count tree, once per health board + an +unfiltered baseline, operating purely on the query cache, saved as a long-format CSV. Ships as a NEW, +self-contained plugin `RdmpCohortBuildHealthBoardBreakdown`. CIC-only. + +## 0. Strategy + +Develop + test in `Rdmp.Core` first (so the docker NUnit harness can exercise it, like the final-list +feature), then copy into the new plugin and build the 9.2.3 `.rdmp`. Reuses `HealthBoardLookup` and the +param-hoist / CSV / demography-resolution patterns from the final-list work. + +## 1. Files + +**Core (dev + test):** +- `Rdmp.Core/CohortCreation/CohortBuildHealthBoardBreakdownReport.cs` — long-format projection → CSV. +- `Rdmp.Core/CommandExecution/AtomicCommands/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs` + — the command (CIC input). +- (reuses existing `Rdmp.Core/CohortCreation/HealthBoardLookup.cs`.) + +**Tests:** `Rdmp.Core.Tests/CohortCreation/CohortBuildHealthBoardBreakdownTests.cs`. + +**New plugin (phase 2):** `proposals/cohort-healthboard-breakdown/build-plugin/` — +`RdmpCohortBuildHealthBoardBreakdown.csproj`/`.nuspec`, a `PluginUserInterface` (right-click a CIC), +plus copies of `HealthBoardLookup.cs`, the report and the command. + +## 2. Command flow (`Execute`) + +``` +ExecuteCommandExportCohortBuildHealthBoardBreakdown( + IBasicActivateItems activator, + CohortIdentificationConfiguration cic, + FileInfo toFile = null, // -build-healthboard.csv + string demographyCatalogue = "SHARE_Demography", + string regionColumn = "Region", + int timeout = 5000) +``` + +Construction-time `SetImpossible` guards: +- `cic` null or no root container. +- `cic.QueryCachingServer == null` → "needs a query caching server (cohort spans servers)". +- demography catalogue / `Region` / IsExtractionIdentifier column missing (same resolution as final-list). +- **co-location:** `cic.QueryCachingServer.Server` != `SHARE_Demography` `TableInfo.Server` → impossible + with a clear message (the recompose+join runs on the cache server). + +Execute: +1. **Build once / refresh cache + baseline.** `compiler = new CohortCompiler(activator, cic){ + IncludeCumulativeTotals = true }; new CohortCompilerRunner(compiler, timeout){ RunSubcontainers = + true }.Run(token)`. This populates every per-set cache table and gives the baseline + `FinalRowCount` / `CumulativeRowCount` per node (our Unfiltered column + reconciliation source). + If any set crashes, surface it (don't emit a wrong tree). +2. **Enumerate count points** by walking `cic.RootCohortAggregateContainer` recursively via + `GetOrderedContents()` (respect `Order`, `Operation`, skip `IDisabled`). For each container, in order: + for each child a *set-total* (sets) or *container-total* (sub-containers) point, plus a *cumulative* + point for every non-first enabled child. Carry (Order, Type, Name, ContainerName, SetOperation) — the + same shape as `CohortCountReport`. +3. **Per count point, build the identifier-list SQL directly from the cache tables (AS BUILT).** Every + node is recomposed by hand from the per-set cache tables — `CohortQueryBuilder` is NOT used for the + recompose (only `CohortCompiler` runs once, to populate the cache + give the baseline). This avoids + parameter hoisting entirely (cache tables are bare identifier lists) and guarantees cache-server-only + SQL. + - set cache table: `CachedAggregateConfigurationResultsManager.GetLatestResultsTableUnsafe(agg, + IndexedExtractionIdentifierList)` → `SELECT AS id FROM ` (`CachedSetSql`). + - set total → that set SQL. + - container total → `Compose(container, enabledOrderedChildren)` = `(child0) (child1) ...` with + `` = the container's UNION/INTERSECT/EXCEPT, each arm recursing into `IdSql`. + - cumulative k → `Compose(parentContainer, children.Take(k+1))`. + (No globals / no params needed — the arms are `SELECT id FROM `.) +4. **Split by board in one query per count point:** then + ```sql + SELECT d.[Region] AS Region, COUNT(DISTINCT i.id) AS n + FROM ( ) i + JOIN d ON d.chi = i.id + GROUP BY d.[Region] + ``` + run on the **cache server** (`DataAccessPortal.ExpectDatabase(cacheServer DB)`). One query → all boards. +5. **Assemble long rows.** For each count point: emit one row per board present (mapped via + `HealthBoardLookup`), an `Unknown` row = baseline count − Σ known boards (patients not in demography / + unmapped region), and an `Unfiltered` row = baseline count. `CumulativeCount` filled the same way from + the cumulative query (null where RDMP's cumulative is null — first child / container totals as RDMP does). +6. **Reconcile (assert + report):** for every count point, Σ board FinalCount (+Unknown) must equal the + baseline `FinalRowCount`; Unfiltered must equal baseline. Same for cumulative. Mismatch → warn loudly in + the summary. +7. Write CSV; `BasicActivator.Show` a summary (nodes, boards, any reconciliation drift). + +## 3. Output (long format, board-grouped) + +Columns: `Board, Node, Order, Type, Name, Container, SetOperation, FinalCount, CumulativeCount`. +Row order: **board-major** — `Unfiltered` block first (the exact UI tree), then each board T, G, … each a +full tree, then `Unknown` last. Within a board, rows follow the tree `Order` (so it reads like the UI count +table repeated per board). Reuses the `CohortCountReport` CSV escaper. + +## 4. Why this is cache-only and cross-server-safe + +- Every node (set, container, cumulative) is recomposed by hand from the per-set cache tables, so all + recompose SQL references only cache-server objects. +- The only non-cache object touched is `SHARE_Demography`, required (by the co-location check) to be on the + cache server, so every query is single-server. The source catalogue servers are never touched after step 1 + (the one `CohortCompiler` build). + +## 5. Tests (docker) + +**No-DB:** report projection (long format, board-major ordering, Unknown/Unfiltered rows, reconciliation +helper); the GROUP-BY-Region wrapper string builder. + +**DB end-to-end (`DatabaseTests`, model on `CohortQueryBuilderWithCacheTests` + the final-list DB test):** +build 2–3 small synthetic catalogues with data + a `SHARE_Demography(chi,Region)` table on the docker +server; set the CIC's `QueryCachingServer` to `TEST_QueryCache`; build an EXCEPT-over-INTERSECT CIC; run the +command. Assert: +- Unfiltered Final/Cumulative per node == values from a direct `CohortCompiler` run (parity with UI). +- Per-board + Unknown sums == Unfiltered at every node (reconciliation). +- A hand-computed board (e.g. all cohort members in Tayside except one) matches at the leaf and after the + EXCEPT (proves distributivity through the tree). +Run: `bash mac-test-env/run-tests.sh "FullyQualifiedName~CohortBuildHealthBoard"`. + +> Note: the Mac+docker SQL TLS limitation blocks a full *CLI* data run (as before); execution correctness +> is proven by these NUnit DB tests through the real query cache + CohortCompiler stack. + +## 6. Plugin + 9.2.3 + upload (phase 2) + +- New plugin `RdmpCohortBuildHealthBoardBreakdown` (right-click a CIC). Self-contained (copies of the 3 + sources). Built against the v9.2.3 worktree (core lacks the classes → no CS0433), packaged `.rdmp`, + verified to load (ListSupportedCommands shows `ExportCohortBuildHealthBoardBreakdown`). +- Upload to a **new** OneDrive folder `onedrive:rdmp/healthboard_build_breakdown/` + INSTALL.md, byte-verified. + +## 7. Effort + +Core command + report + tree walk + per-point query: ~1.5 days. Tests (no-DB + docker cache E2E): ~1 day. +Plugin + 9.2.3 + upload (pipeline exists): ~0.5 day. **≈ 3 days.** + +## 8. Checklist + +- [x] `CohortBuildHealthBoardBreakdownReport` (long CSV) + no-DB tests +- [x] count-point tree walk (parity with `CohortCountReport` enumeration) +- [x] command: build-once + co-location/cache guards + per-point GROUP BY Region on cache server +- [x] reconciliation asserts (Unfiltered == RDMP; boards+Unknown == Unfiltered) +- [x] docker DB end-to-end (cache + EXCEPT/INTERSECT) green + (`CohortBuildHealthBoardBreakdownTests` — the fixture in BUILD-BREAKDOWN-TEST-FIXTURE.md: + national 100→80→65→60→58, Tayside 50→40→32→30→29, Glasgow→17, Fife→12, Unknown=20, + INTERSECT cumulative=100, partition-sums-to-national at every node; passes) +- [x] new plugin + UI hook + 9.2.3 `.rdmp` load-verified + (`RdmpCohortBuildHealthBoardBreakdown` — right-click a CIC; built vs vanilla v9.2.3 worktree; + `.rdmp` loads `ExportCohortBuildHealthBoardBreakdown` into the 9.2.3 CLI, absent without it) +- [x] upload to onedrive:rdmp/healthboard_build_breakdown/ (byte-verified, 15302 bytes) + INSTALL.md diff --git a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-TEST-FIXTURE.md b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-TEST-FIXTURE.md new file mode 100644 index 0000000000..fd9c0031a6 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-TEST-FIXTURE.md @@ -0,0 +1,87 @@ +# Validation fixture — per-health-board build breakdown (docker, synthetic) + +Fully deterministic. 1 patient ↔ 1 board, so the 3 boards **partition** the cohort and sum to the +national number at *every* node (set total and cumulative). Exercises INTERSECT, EXCEPT, cumulative +through the tree, and the "no region" Unknown bucket. + +## People (120 ids: P001–P120) + +| Group | IDs | Count | Region | +|---|---|---|---| +| Tayside | P001–P050 | 50 | T | +| Glasgow | P051–P080 | 30 | G | +| Fife | P081–P100 | 20 | F | +| Registry-only (no demography row) | P101–P120 | 20 | — (Unknown) | + +## Catalogues / tables (all on one docker server) + +- **BB_Demography** (`chi, Region`): P001–P100 with their Region (the only place Region lives; the + reference table). 100 rows. +- **BB_Registry** (`chi`): P001–P120 (the cohort source; includes 20 people with no demography row). 120 rows. +- **BB_Excl1..4** (`chi`): disjoint exclusion subsets, all within P001–P100 (each = exactly the people it removes): + +| Excl | Tayside | Glasgow | Fife | Total | +|---|---|---|---|---| +| Excl1 | P001–P010 (10) | P051–P056 (6) | P081–P084 (4) | 20 | +| Excl2 | P011–P018 (8) | P057–P060 (4) | P085–P087 (3) | 15 | +| Excl3 | P019–P020 (2) | P061–P062 (2) | P088 (1) | 5 | +| Excl4 | P021 (1) | P063 (1) | — (0) | 2 | + +## CIC structure + +``` +ROOT (EXCEPT) +├─ Inclusion (INTERSECT) +│ ├─ BB_Registry set total 120 +│ └─ BB_Demography set total 100 ⇒ Inclusion = 100 +├─ BB_Excl1 set total 20 +├─ BB_Excl2 set total 15 +├─ BB_Excl3 set total 5 +└─ BB_Excl4 set total 2 ⇒ ROOT (national cohort) = 58 +``` + +Child order matters (EXCEPT/cumulative): Inclusion first, then Excl1..4 in order. + +## Expected count tree (what the plugin must reproduce) + +`FinalCount` = the node's own set/container count. `CumulativeCount` = running total within the +container (null for the first child, per the UI). + +### Inclusion container (INTERSECT) +| Node | Final (Nat) | Cum (Nat) | T | G | F | Unknown | +|---|---|---|---|---|---|---| +| BB_Registry (set) | 120 | — | 50 | 30 | 20 | **20** | +| BB_Demography (set) | 100 | 100 | 50 | 30 | 20 | 0 | +| Inclusion (total) | 100 | — | 50 | 30 | 20 | 0 | + +(The 20 registry-only people show up under Unknown on the BB_Registry set, then the INTERSECT with +demography drops them — demonstrating the Unknown bucket and that no-region people don't leak.) + +### ROOT container (EXCEPT) — cumulative is the key check +| Node | Final (Nat) | Cum (Nat) | Cum T | Cum G | Cum F | +|---|---|---|---|---|---| +| Inclusion (child 0) | 100 | — | 50 | 30 | 20 | +| BB_Excl1 | 20 | **80** | 40 | 24 | 16 | +| BB_Excl2 | 15 | **65** | 32 | 20 | 13 | +| BB_Excl3 | 5 | **60** | 30 | 18 | 12 | +| BB_Excl4 | 2 | **58** | 29 | 17 | 12 | +| ROOT (total) | 58 | — | 29 | 17 | 12 | + +National cumulative: 100 → 80 → 65 → 60 → 58. +Per-board cumulative diverges (Tayside −21, Glasgow −13, Fife −8) and **T+G+F = national at every row** +(40+24+16=80, 32+20+13=65, 30+18+12=60, 29+17+12=58). That cross-check is the automated assertion. + +## What this validates + +- INTERSECT (120 ∩ 100 = 100) and EXCEPT cumulative down the tree. +- Per-board cumulative correctness via distributivity (boards partition → sum to national everywhere). +- The Unknown / not-in-demography bucket (the 20 registry-only ids). +- The unfiltered column equals RDMP's own `CohortCompiler` counts (separate assertion). + +## Test mechanics (docker) + +`DatabaseTests` fixture: create the 6 tables with the data above on the docker server, import as +catalogues, set BB_Demography.chi + BB_Registry.chi + each Excl.chi as IsExtractionIdentifier, set +BB_Demography.Region; build the CIC tree above; set `QueryCachingServer = TEST_QueryCache`; run the +command; assert the full table above (national + T/G/F + Unknown) cell-by-cell, and that every node's +T+G+F(+Unknown) sums to its national value. diff --git a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md new file mode 100644 index 0000000000..4d74ea6114 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md @@ -0,0 +1,94 @@ +# Plan — wide ("horizontal") build-breakdown report + +Changes the cohort-build health-board breakdown output from the current **long** format (one row per +count-point × board) to a **wide/horizontal** matrix: one row per count point with the container/set +name once, a `Total` column, one column per health board, plus an explicit split of the old catch-all +`Unknown`. Adds a bottom **% contribution** block and keeps the **breakdown-sums-to-national** check. + +Status: PLAN (not yet implemented). Affects only `CohortBuildHealthBoardBreakdownReport` (the +projection/CSV) and a few assertions; the cache-only recompose engine in the command is unchanged. + +## 1. Layout + +One row per count point (tree order), name written once. Columns: + +``` +Order | Type | Name | Container | SetOperation | Metric | Total | | Other | NotKnown +``` + +- `Total` — the national (non-breakdown) number for that node = RDMP's own count. Kept as a column. +- `` — one column per Scottish health board (the 15 from HealthBoardLookup; only those + that appear, ordered by node then name). +- `Other` — patients whose region code IS present in demography but is **not** one of the 15 Scottish + boards (non-Scottish / unmapped codes). NEW — split out of the old Unknown so non-Scottish boards are + visible. (Optionally each distinct other code as its own column — see §4 decision.) +- `NotKnown` — residual = `Total − Σ(boards) − Other` = patients **not in demography** + **NULL region**. + This is the "we genuinely can't place them" bucket. +- `Metric` — `Final` or `Cumulative` (see §4 decision on whether we keep both). + +Every data row satisfies: `Σ(boards) + Other + NotKnown == Total`. + +### Sample (national cohort = 58, fixture numbers) + +``` +Order Type Name Container SetOp Metric Total Tayside Glasgow Fife Other NotKnown +0 Container Root EXCEPT Cumulative 58 29 17 12 0 0 +1 Container Inclusion Root INTER Final 100 50 30 20 0 0 +2 Cohort Set Registry Inclusion Final 120 50 30 20 0 20 +... +``` + +## 2. Bottom block — % contribution to the total + +After the data rows, a separator then a `% of final cohort` section: for the **final cohort** (the root +node), each board's share = `board / Total × 100`. One row: + +``` +% of final cohort 100.0 50.0 29.3 20.7 0.0 0.0 +``` + +(Tayside 29/58 = 50.0%, Glasgow 17/58 = 29.3%, Fife 12/58 = 20.7%.) Percentages computed from the +chosen Metric's root row. Option to also emit a per-row `%` block (each node's board split) — see §4. + +## 3. The "Unknown" split (answers the non-Scottish question) + +Old behaviour: `Unknown = Total − Σ(15 boards)` — merged non-Scottish codes + NULL region + not-in- +demography into one number. New behaviour, using data we already fetch (the GROUP BY returns every +present code): + +| Bucket | Definition | Source | +|---|---|---| +| board columns | the 15 Scottish ciphers | GROUP BY rows where `HealthBoardLookup.Resolve(code)` is a real board | +| `Other` | present region codes NOT in the 15 (non-Scottish / unmapped) | GROUP BY rows where Resolve → Unknown node | +| `NotKnown` | `Total − Σ(boards) − Other` | residual = not-in-demography + NULL region | + +So non-Scottish boards are no longer hidden — they land in `Other` (or their own columns, §4), and +`NotKnown` becomes a clean "no usable location" figure. (NULL region stays inside `NotKnown`; splitting +NULL from not-in-demography is possible but low value — noted, not planned.) + +## 4. Reconciliation check (kept + strengthened) + +Keep "breakdown sums to the national non-breakdown search": +- `Total` per node already = RDMP's `CohortCompiler` count (the non-breakdown national number). +- New genuine check: `Σ(boards) + Other` is computed from the independent GROUP BY query; assert it is + `≤ Total`, and define `NotKnown = Total − that` (so the row always reconciles, and a negative + `NotKnown` would flag a key/join bug). The DB test additionally asserts every cell against the + hand-derived fixture, so the equality is a real check, not a tautology. + +## 5. Decisions (confirmed 2026-06-27) + +1. **Metric = Both** — a `Metric` column; two rows per node (`Final` then `Cumulative`, the latter only + where RDMP has a cumulative). Node name repeats across its two metric rows. +2. **Other = one combined column** — all present non-Scottish/unmapped codes summed into a single `Other` + column; `NotKnown` is the separate residual (not-in-demography + NULL region). +3. **Percentages = bottom row only** — a single `% of final cohort` row (each board's share of the root + final cohort), after a blank separator. + +Final column order: `Order, Type, Name, Container, SetOperation, Metric, Total, , Other, NotKnown`. + +## 6. Scope / effort + +- Change is isolated to `CohortBuildHealthBoardBreakdownReport` (new wide `BuildWide`/`ToCsv`) + the + command's drift/reconcile note + the DB test assertions (now read columns instead of board rows). +- The final-list report (`HealthBoardBreakdownReport`) is left as-is unless you also want it widened. +- Est. ~0.5–1 day incl. updated docker test, then refresh the build-plugin `.rdmp` + OneDrive. diff --git a/RdmpCohortBuildHealthBoardBreakdown/docs/TECHNICAL-BACKGROUND.html b/RdmpCohortBuildHealthBoardBreakdown/docs/TECHNICAL-BACKGROUND.html new file mode 100644 index 0000000000..3313ace436 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/docs/TECHNICAL-BACKGROUND.html @@ -0,0 +1,230 @@ + + + + + +Cohort Health-Board Breakdown — Technical Background + + + + +

Cohort Health-Board Breakdown

+

Technical background — how the two RDMP plugins work, what runs at each step, and the +actual SQL they generate. Written to be read top-to-bottom; no prior RDMP knowledge assumed.

+ +

1. What problem this solves

+

An RDMP cohort is a list of patients defined by a tree of inclusion/exclusion rules +(e.g. “diabetics, except those who died, intersect those on drug X”). RDMP can already tell you +how many patients are in the cohort at each step. What it cannot do is tell you how those +numbers split by Scottish health board. These two plugins add exactly that:

+
    +
  • Final-list breakdown — for a finished cohort, how many patients fall in each health board.
  • +
  • Build-tree breakdown — reproduces RDMP’s per-step counts (and the running totals as each + filter is applied), split out per board, next to the national total.
  • +
+ +

2. The intended workflow

+

+1Build the national cohort in RDMP (this also fills RDMP’s cache).
+2Check the split with the build-tree plugin — see how each filter trims each board.
+3Refine the filters and re-check (steps 2–3 loop).
+4Freeze the cohort, then run the final-list plugin for the committed per-board headcount. +

+ +

3. Two key ideas that make it work

+ +

Idea A — splitting by board is just a filter, so it “distributes”

+

Restricting the whole cohort to one board is the same as taking the result and keeping only that +board’s patients. Mathematically, that filter passes straight through the cohort’s set operations:

+
(A UNION B)     restricted to a board  =  (A restricted) UNION (B restricted)
+(A INTERSECT B) restricted to a board  =  (A restricted) INTERSECT (B restricted)
+(A EXCEPT B)    restricted to a board  =  (A restricted) EXCEPT (B restricted)
+
Practically — we never rebuild the cohort once per board (15×). We compute the +national cohort once, then slice the result by board. Same answer, a fraction of the work.
+ +

Idea B — RDMP already caches the building blocks

+

When RDMP builds a cohort it saves each individual rule’s patient list into a query cache — a +real, queryable SQL table. It does not cache the combined or running totals (it recomputes those in +memory). So the cache gives us the raw ingredients; we recombine them ourselves.

+
Practically — after the one national build, every per-board number comes from +those cached tables. The original source databases are not touched again.
+ +

4. Step by step — what runs, and the actual SQL

+ +

Step 1 — Build the cohort once (RDMP’s engine)

+

We call RDMP’s own build engine. This runs the cohort, fills the cache, and hands back the national +counts at every node.

+
// RDMP classes — we don't reimplement any of this
+var compiler = new CohortCompiler(activator, cic) { IncludeCumulativeTotals = true };
+var runner   = new CohortCompilerRunner(compiler, timeout) { RunSubcontainers = true };
+runner.Run(token);   // executes the cohort, populates the cache, computes baseline counts
+

Each rule’s patient list is now a table in the cache, named by RDMP like:

+
[QueryCache]..[IndexedExtractionIdentifierList_AggregateConfiguration1234]
+   chi
+   ----------
+   1010101010
+   2020202020
+   ...            (one column: the patient identifier / CHI)
+ +

Step 2 — Walk the cohort definition (RDMP’s tree model)

+

We read the cohort’s structure using RDMP’s own tree, so our breakdown matches its exact shape and +order (UNION / INTERSECT / EXCEPT, child order, disabled rules skipped):

+
foreach (var child in container.GetOrderedContents())   // RDMP gives the ordered tree
+{
+    // child is either a rule (AggregateConfiguration) or a sub-container
+    // container.Operation is UNION / INTERSECT / EXCEPT
+}
+ +

Step 3 — Rebuild each count point from the cache (our SQL)

+

For a single rule, the “patient list” is just its cache table:

+
SELECT chi AS id FROM [QueryCache]..[IndexedExtractionIdentifierList_AggregateConfiguration1234]
+

For a container, we stitch its children together with its operation. Example — the inclusion container +is Registry INTERSECT Demography:

+
(SELECT chi AS id FROM [QueryCache]..[...Registry])
+INTERSECT
+(SELECT chi AS id FROM [QueryCache]..[...Demography])
+

For a cumulative (running-total) point — e.g. “the cohort after applying Excl1 and Excl2” — we +combine the rules up to that point, in order:

+
(
+   (SELECT chi AS id FROM [...Registry]) INTERSECT (SELECT chi AS id FROM [...Demography])
+)
+EXCEPT (SELECT chi AS id FROM [...Excl1])
+EXCEPT (SELECT chi AS id FROM [...Excl2])
+
Hand-written — RDMP caches only the individual rules, not these combinations, +so this recombination SQL is ours. It’s a direct translation of the tree (one UNION/INTERSECT/EXCEPT +per branch). In code it’s a few lines: +
string Compose(container, children) =>
+    string.Join("\n" + container.Operation + "\n",
+                children.Select(ch => "(" + IdSql(ch) + ")"));
+
+ +

Step 4 — Split that list by health board (our SQL)

+

We wrap any of the lists above and join it to the demography table to attach a region, then group:

+
SELECT d.[Region] AS Region, COUNT(DISTINCT i.id) AS n
+FROM ( <the list from step 3> ) i
+INNER JOIN [SHARE]..[Demography] d ON d.[chi] = i.id
+GROUP BY d.[Region]
+

One query returns every board at once:

+
Region   n
+------   --
+T        32
+G        20
+F        13
+
Practically — one small query per count point gives all boards. There are a few +dozen count points in a typical cohort, so the whole breakdown is a few dozen cheap cache queries — not a +full rebuild, and not one-query-per-board.
+ +

Step 5 — Translate region codes to boards (hard-coded lookup)

+

The demography table stores a one-letter cipher (T, G…). +RDMP has no concept of Scottish health boards, so we keep a fixed lookup:

+
["T"] = new("T", 3,  "Tayside",                 "East"),
+["G"] = new("G", 16, "Greater Glasgow & Clyde", "West"),
+["F"] = new("F", 4,  "Fife",                    "East"),
+// ... 15 Scottish boards, each: cipher -> (name, HB code, node)
+
+HealthBoard Resolve(code) => known(code) ? thatBoard : /* fall-through */ Unknown;
+
Hard-coded — this lookup table is the one piece RDMP can’t supply; it came from +the SHARE demography region codes. It lives in a single small file so it’s easy to update if codes change.
+ +

Step 6 — Sort into Boards / Other / NotKnown (our logic)

+

Every region code the query returned is sorted into one of three buckets:

+
foreach (var (code, n) in resultsFromStep4)
+    if (Resolve(code) is a real Scottish board)  boards[code] = n;   // a column
+    else                                         other      += n;   // non-Scottish / unmapped
+
+notKnown = nationalTotal - sum(boards) - other;   // not in demography, or null region
+ + + + + +
BucketMeaning
Board columnsthe 15 recognised Scottish boards
Othera region code that is present but isn’t a Scottish board (e.g. an + out-of-area / non-Scottish code) — previously these were invisible
NotKnownpatients with no demography record at all, or a blank region
+ +

5. The output (build-tree plugin)

+

A wide CSV: one row per rule/container (name written once), a Total column (RDMP’s national +number), a column per board, then Other and NotKnown, and a percentage row at the bottom. Final += that rule’s own count; Cumulative = the running cohort size after that step. The +column header is repeated just above the % row so each percentage lines up under its board.

+
Name        Metric      Total  Fife  Tayside  Glasgow  Other  NotKnown
+Root        Final          58    12       29       17      0         0
+Inclusion   Final         100    20       50       30      0         0
+Registry    Final         120    20       50       30      0        20
+Excl1       Cumulative     80    16       40       24      0         0
+Excl2       Cumulative     65    13       32       20      0         0
+Excl4       Cumulative     58    12       29       17      0         0
+
+Name        Metric      Total  Fife  Tayside  Glasgow  Other  NotKnown   <- header repeated
+% of final cohort         100.0  20.7     50.0     29.3    0.0       0.0
+
Clean names — RDMP internally prefixes set names with cic_<id>_, +and cloning a cohort across configurations stacks them (e.g. +cic_18286_cic_18284_cic_17950_People in SHARE…). The report strips those prefixes for +display, so you see the real name (People in SHARE Current For Contact [Recruitment], +Excl Grp 1 and 2).
+ +

6. The built-in correctness check

+

On every row, the board columns plus Other plus NotKnown must add back up to the Total — and the Total +is RDMP’s own national figure. If they ever disagreed, it would flag a data/key problem.

+
sum(board columns) + Other + NotKnown  ==  Total   // checked on every row
+

We also validate the whole thing against a synthetic dataset with hand-calculated numbers (a +cohort of 100 split across three boards) — the plugin reproduces every figure exactly.

+ +

7. Summary — reused vs. built

+ + + + + + +
RDMP internals we reusedWhat we wrote / hard-coded
+
    +
  • CohortCompiler / CohortCompilerRunner — build + counts
  • +
  • CachedAggregateConfigurationResultsManager — the query cache
  • +
  • CohortAggregateContainer tree (order, operation)
  • +
  • ExtractableCohort / CohortQueryBuilder — cohort identifier lists
  • +
  • DataAccessPortal + FAnsi — run SQL safely
  • +
  • Command + plugin framework (CLI verb, GUI right-click, .rdmp packaging)
  • +
+
+
    +
  • The Scottish health-board lookup (cipher → board/node)
  • +
  • The recombination SQL (UNION/INTERSECT/EXCEPT over cache tables)
  • +
  • The GROUP BY region join
  • +
  • Other / NotKnown bucketing + the wide report + percentages
  • +
  • Defaults: catalogue SHARE_Demography, column Region, join on CHI, same-server
  • +
+
+ +

+One honest caveat: the board mapping is hard-coded, so if region codes or board groupings change, that one +lookup file needs updating. Everything else is driven by RDMP’s own model of the cohort.

+ + + diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownPluginUserInterface.cs b/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownPluginUserInterface.cs new file mode 100644 index 0000000000..82c66c46c0 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownPluginUserInterface.cs @@ -0,0 +1,24 @@ +// Surfaces the cohort build health board breakdown in the RDMP desktop GUI (right-click a cohort +// identification configuration). The same command class is auto-discovered for the CLI +// (`rdmp cmd ExportCohortBuildHealthBoardBreakdown`), so one definition serves both. + +using System.Collections.Generic; +using Rdmp.Core; +using Rdmp.Core.CommandExecution; +using Rdmp.Core.CommandExecution.AtomicCommands; +using Rdmp.Core.Curation.Data.Cohort; + +namespace RdmpCohortBuildHealthBoardBreakdown; + +public class CohortBuildHealthBoardBreakdownPluginUserInterface : PluginUserInterface +{ + public CohortBuildHealthBoardBreakdownPluginUserInterface(IBasicActivateItems itemActivator) : base(itemActivator) + { + } + + public override IEnumerable GetAdditionalRightClickMenuItems(object o) + { + if (o is CohortIdentificationConfiguration cic) + yield return new ExecuteCommandExportCohortBuildHealthBoardBreakdown(BasicActivator, cic); + } +} diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs b/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs new file mode 100644 index 0000000000..e3c8450aa3 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs @@ -0,0 +1,174 @@ +// Copyright (c) The University of Dundee 2024-2024 +// This file is part of the Research Data Management Platform (RDMP). +// RDMP is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// RDMP is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with RDMP. If not, see . + +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace Rdmp.Core.CohortCreation; + +/// +/// Projects a cohort build's count tree (the per-set / per-container FinalCount and cumulative +/// running totals shown in the Cohort Builder) split by health board into a WIDE CSV: one row per +/// (count-point × metric), the container/set name written once, a Total column (RDMP's own +/// national count), one column per Scottish health board, an Other column (present non-Scottish / +/// unmapped region codes) and a NotKnown residual (patients not in demography / NULL region). +/// A bottom % of final cohort row gives each board's share of the final national cohort. +/// Boards + Other + NotKnown reconcile to Total on every row. +/// +public static class CohortBuildHealthBoardBreakdownReport +{ + public const string OtherColumn = "Other"; + public const string NotKnownColumn = "NotKnown"; + public const string PercentMetric = "% of final cohort"; + + /// One count point of the build tree with its per-region counts (known boards only). + public sealed class NodeBreakdown + { + public int Seq { get; init; } + public string Type { get; init; } = ""; + public string Name { get; init; } = ""; + + /// Parent container name (empty for the root). + public string Container { get; init; } = ""; + + public string SetOperation { get; init; } = ""; + public int DisplayOrder { get; init; } + + /// RDMP's own count for this node (the unfiltered/national total). + public int FinalUnfiltered { get; init; } + + /// RDMP's own cumulative within the parent container; null if not applicable. + public int? CumulativeUnfiltered { get; init; } + + /// Region cipher → final count (every present code; GROUP BY Region result). + public IReadOnlyDictionary FinalByRegion { get; init; } = new Dictionary(); + + /// Region cipher → cumulative count; null when this node has no cumulative. + public IReadOnlyDictionary CumulativeByRegion { get; init; } + } + + /// The Total / per-board / Other / NotKnown counts for one node+metric. + public sealed class Buckets + { + public int Total { get; init; } + + /// Region cipher → count (mapped Scottish boards only). + public IReadOnlyDictionary Boards { get; init; } = new Dictionary(); + + /// Sum of present region codes that are NOT one of the 15 Scottish boards. + public int Other { get; init; } + + /// Total − boards − Other = not-in-demography + NULL region. + public int NotKnown { get; init; } + } + + /// + /// Splits one node's region counts into Total / mapped-boards / Other / NotKnown. + /// is the GROUP BY Region result (every present code); is RDMP's own count. + /// + public static Buckets Split(int total, IReadOnlyDictionary byRegion) + { + var boards = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + var other = 0; + foreach (var (code, n) in byRegion) + if (HealthBoardLookup.Resolve(code).Node == HealthBoardLookup.UnknownNode) + other += n; // present but not a Scottish board (non-Scottish / unmapped) + else + boards[code] = n; + + return new Buckets + { + Total = total, + Boards = boards, + Other = other, + NotKnown = total - boards.Values.Sum() - other + }; + } + + /// The ordered mapped boards that appear anywhere (column order: node then name). + private static List BoardColumns(IEnumerable nodes) => + nodes + .SelectMany(n => n.FinalByRegion.Keys.Concat(n.CumulativeByRegion?.Keys ?? Enumerable.Empty())) + .Select(HealthBoardLookup.Resolve) + .Where(b => b.Node != HealthBoardLookup.UnknownNode) + .GroupBy(b => b.Region, System.StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .OrderBy(b => b.Node, System.StringComparer.OrdinalIgnoreCase) + .ThenBy(b => b.Name, System.StringComparer.OrdinalIgnoreCase) + .ToList(); + + /// Builds the wide CSV (data rows per node+metric, then a % of final cohort row). + public static string ToCsv(IReadOnlyList nodes) + { + var ordered = nodes.OrderBy(n => n.Seq).ToList(); + var boards = BoardColumns(ordered); + + var header = new List { "Order", "Type", "Name", "Container", "SetOperation", "Metric", "Total" }; + header.AddRange(boards.Select(b => b.Name)); + header.Add(OtherColumn); + header.Add(NotKnownColumn); + + var sb = new StringBuilder(); + sb.AppendLine(string.Join(",", header.Select(Escape))); + + foreach (var n in ordered) + { + AppendCountRow(sb, n, boards, "Final", Split(n.FinalUnfiltered, n.FinalByRegion)); + if (n.CumulativeUnfiltered.HasValue && n.CumulativeByRegion != null) + AppendCountRow(sb, n, boards, "Cumulative", + Split(n.CumulativeUnfiltered.Value, n.CumulativeByRegion)); + } + + // bottom: % of final cohort (root node's Final), after a blank separator + var root = ordered.FirstOrDefault(n => string.IsNullOrEmpty(n.Container)) ?? ordered.FirstOrDefault(); + if (root != null && root.FinalUnfiltered > 0) + { + sb.AppendLine(); + sb.AppendLine(string.Join(",", header.Select(Escape))); // repeat header so % aligns to each board + var b = Split(root.FinalUnfiltered, root.FinalByRegion); + double Pct(int v) => v * 100.0 / b.Total; + var cells = new List { "", "", PercentMetric, "", "", PercentMetric, Fmt(100.0) }; + cells.AddRange(boards.Select(bd => Fmt(Pct(b.Boards.TryGetValue(bd.Region, out var v) ? v : 0)))); + cells.Add(Fmt(Pct(b.Other))); + cells.Add(Fmt(Pct(b.NotKnown))); + sb.AppendLine(string.Join(",", cells.Select(Escape))); + } + + return sb.ToString(); + } + + private static void AppendCountRow(StringBuilder sb, NodeBreakdown n, List boards, + string metric, Buckets b) + { + var cells = new List + { + n.DisplayOrder.ToString(CultureInfo.InvariantCulture), + n.Type, n.Name, n.Container, n.SetOperation, metric, + b.Total.ToString(CultureInfo.InvariantCulture) + }; + cells.AddRange(boards.Select(bd => + (b.Boards.TryGetValue(bd.Region, out var v) ? v : 0).ToString(CultureInfo.InvariantCulture))); + cells.Add(b.Other.ToString(CultureInfo.InvariantCulture)); + cells.Add(b.NotKnown.ToString(CultureInfo.InvariantCulture)); + sb.AppendLine(string.Join(",", cells.Select(Escape))); + } + + public static void WriteCsv(string path, IReadOnlyList nodes) => + File.WriteAllText(path, ToCsv(nodes)); + + private static string Fmt(double d) => d.ToString("0.0", CultureInfo.InvariantCulture); + + private static string Escape(string field) + { + field ??= ""; + if (field.Contains(',') || field.Contains('"') || field.Contains('\n') || field.Contains('\r')) + return $"\"{field.Replace("\"", "\"\"")}\""; + return field; + } +} diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs b/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs new file mode 100644 index 0000000000..e725d4eaf4 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs @@ -0,0 +1,327 @@ +// Copyright (c) The University of Dundee 2024-2024 +// This file is part of the Research Data Management Platform (RDMP). +// RDMP is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// RDMP is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with RDMP. If not, see . + +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using FAnsi.Discovery; +using Rdmp.Core.CohortCreation; +using Rdmp.Core.CohortCreation.Execution; +using Rdmp.Core.Curation.Data; +using Rdmp.Core.Curation.Data.Aggregation; +using Rdmp.Core.Curation.Data.Cohort; +using Rdmp.Core.MapsDirectlyToDatabaseTable; +using Rdmp.Core.QueryCaching.Aggregation; +using Rdmp.Core.ReusableLibraryCode.DataAccess; + +namespace Rdmp.Core.CommandExecution.AtomicCommands; + +/// +/// Reproduces the Cohort Builder's per-set / per-container count tree (the FinalCount and cumulative +/// running totals shown as UNION/INTERSECT/EXCEPT are applied) split by Scottish health board, and +/// writes it to a long-format CSV. Operates purely on the query cache: it builds the cohort once to +/// populate the per-set cache tables, then recomposes every count point from those cache tables and +/// splits it by SHARE_Demography.Region with one GROUP BY per node (all boards at once). +/// +public class ExecuteCommandExportCohortBuildHealthBoardBreakdown : BasicCommandExecution +{ + private readonly CohortIdentificationConfiguration _cic; + private readonly string _demographyCatalogue; + private readonly string _regionColumn; + private readonly int _timeout; + private FileInfo _toFile; + + private ExtractionInformation _regionEi; + private ExtractionInformation _idEi; + + private DiscoveredDatabase _cacheDb; + private CachedAggregateConfigurationResultsManager _cacheManager; + private string _demogTable; + private string _demogId; + private string _regionName; + + private readonly Dictionary _setCacheTable = new(); + private readonly Dictionary<(bool isContainer, int id), (int final, int? cumulative)> _baseline = new(); + + public ExecuteCommandExportCohortBuildHealthBoardBreakdown(IBasicActivateItems activator, + [DemandsInitialization("The cohort identification configuration whose build tree to break down")] + CohortIdentificationConfiguration cic, + [DemandsInitialization("CSV file to write. Defaults to -build-healthboard.csv in the current directory")] + FileInfo toFile = null, + [DemandsInitialization("Demography catalogue holding the region column", DefaultValue = "SHARE_Demography")] + string demographyCatalogue = "SHARE_Demography", + [DemandsInitialization("Region (health board cipher) column on the demography catalogue", DefaultValue = "Region")] + string regionColumn = "Region", + [DemandsInitialization("Per-query command timeout in seconds", DefaultValue = 5000)] + int timeout = 5000) : base(activator) + { + _cic = cic; + _demographyCatalogue = demographyCatalogue; + _regionColumn = regionColumn; + _timeout = timeout; + _toFile = toFile; + + if (_cic == null) + { + SetImpossible("No CohortIdentificationConfiguration was supplied"); + return; + } + + if (_cic.RootCohortAggregateContainer_ID == null) + { + SetImpossible($"'{_cic}' has no root container to run"); + return; + } + + if (_cic.QueryCachingServer_ID == null) + { + SetImpossible($"'{_cic}' has no query caching server - this breakdown works only on cached results"); + return; + } + + ResolveDemography(activator); + } + + private void ResolveDemography(IBasicActivateItems activator) + { + var demography = activator.RepositoryLocator.CatalogueRepository + .GetAllObjects() + .FirstOrDefault(c => string.Equals(c.Name, _demographyCatalogue, System.StringComparison.OrdinalIgnoreCase)); + + if (demography == null) + { + SetImpossible($"Could not find a catalogue called '{_demographyCatalogue}'"); + return; + } + + var eis = demography.GetAllExtractionInformation(ExtractionCategory.Any); + _regionEi = eis.FirstOrDefault(e => + string.Equals(e.GetRuntimeName(), _regionColumn, System.StringComparison.OrdinalIgnoreCase)); + _idEi = eis.FirstOrDefault(e => e.IsExtractionIdentifier); + + if (_regionEi == null) + { + SetImpossible($"'{_demographyCatalogue}' has no column called '{_regionColumn}'"); + return; + } + + if (_idEi == null) + { + SetImpossible($"'{_demographyCatalogue}' has no IsExtractionIdentifier column to join the cohort on"); + return; + } + + // co-location: the recompose + GROUP BY join runs on the cache server, so demography must be there + var cacheServer = _cic.QueryCachingServer.Server; + var demogServer = _idEi.ColumnInfo.TableInfo.Server; + if (!string.IsNullOrWhiteSpace(cacheServer) && !string.IsNullOrWhiteSpace(demogServer) + && !string.Equals(cacheServer.Trim(), demogServer.Trim(), System.StringComparison.OrdinalIgnoreCase)) + SetImpossible( + $"Demography catalogue is on server '{demogServer}' but the query cache is on '{cacheServer}'. " + + "This breakdown joins on the cache server, so they must be the same server."); + } + + public override void Execute() + { + base.Execute(); + + _toFile ??= BasicActivator.IsInteractive + ? BasicActivator.SelectFile("Path to write build health board breakdown to", "Build health board breakdown", "*.csv") + : new FileInfo(Path.Combine(System.Environment.CurrentDirectory, $"{Sanitise(_cic.Name)}-build-healthboard.csv")); + + if (_toFile == null) + return; + + _cacheDb = _cic.QueryCachingServer.Discover(DataAccessContext.InternalDataProcessing); + _cacheManager = new CachedAggregateConfigurationResultsManager(_cic.QueryCachingServer); + _demogTable = _idEi.ColumnInfo.TableInfo.Name; + _demogId = _idEi.GetRuntimeName(); + _regionName = _regionEi.GetRuntimeName(); + + // 1. Build once: populates every per-set cache table and gives the baseline (unfiltered) counts. + var compiler = new CohortCompiler(BasicActivator, _cic) { IncludeCumulativeTotals = true }; + var runner = new CohortCompilerRunner(compiler, _timeout) { RunSubcontainers = true }; + runner.Run(new CancellationToken()); + + var crashed = compiler.Tasks.Keys.Where(t => t.State == CompilationState.Crashed).ToList(); + if (crashed.Any()) + { + SetImpossible($"{crashed.Count} task(s) crashed during the build - cannot produce a reliable breakdown"); + BasicActivator.Show($"Build failed: {crashed[0].CrashMessage?.Message}"); + return; + } + + foreach (var task in compiler.Tasks.Keys) + { + var isContainer = task switch + { + AggregationContainerTask => true, + AggregationTask => false, + _ => (bool?)null // skip joinables / plugin tasks + }; + if (isContainer == null || task.Child == null) + continue; + _baseline[(isContainer.Value, task.Child.ID)] = + (task.FinalRowCount, task.CumulativeRowCount); + } + + // 2. Walk the tree, recomposing each count point from the cache and splitting by region. + var nodes = new List(); + var seq = 0; + Walk(_cic.RootCohortAggregateContainer, null, 0, nodes, ref seq); + + CohortBuildHealthBoardBreakdownReport.WriteCsv(_toFile.FullName, nodes); + + // reconciliation note + var drift = nodes.Count(n => + n.FinalByRegion.Where(kv => HealthBoardLookup.Resolve(kv.Key).Node != HealthBoardLookup.UnknownNode) + .Sum(kv => kv.Value) > n.FinalUnfiltered); + var summary = $"Exported build health board breakdown to {_toFile.FullName} ({nodes.Count} count points)"; + if (drift > 0) + summary += $" - WARNING: {drift} node(s) have board counts exceeding the unfiltered total (check demography keys)"; + BasicActivator.Show(summary); + } + + private void Walk(CohortAggregateContainer container, CohortAggregateContainer parent, int indexInParent, + List nodes, ref int seq) + { + // container node row (cumulative is within its parent) + var (cFinal, cCum) = _baseline.TryGetValue((true, container.ID), out var cb) ? cb : (0, null); + IReadOnlyDictionary cCumByRegion = null; + if (parent != null && indexInParent > 0 && cCum.HasValue) + cCumByRegion = RunRegionCounts(CumulativeSql(parent, indexInParent)); + + nodes.Add(new CohortBuildHealthBoardBreakdownReport.NodeBreakdown + { + Seq = seq++, + Type = "Container", + Name = CleanName(container.Name), + Container = CleanName(parent?.Name), + SetOperation = container.Operation.ToString(), + DisplayOrder = container.Order, + FinalUnfiltered = cFinal, + CumulativeUnfiltered = parent != null && indexInParent > 0 ? cCum : null, + FinalByRegion = RunRegionCounts(IdSql(container)), + CumulativeByRegion = cCumByRegion + }); + + var kids = EnabledOrdered(container); + for (var i = 0; i < kids.Count; i++) + { + switch (kids[i]) + { + case AggregateConfiguration agg: + var (aFinal, aCum) = _baseline.TryGetValue((false, agg.ID), out var ab) ? ab : (0, null); + IReadOnlyDictionary aCumByRegion = null; + if (i > 0 && aCum.HasValue) + aCumByRegion = RunRegionCounts(CumulativeSql(container, i)); + + nodes.Add(new CohortBuildHealthBoardBreakdownReport.NodeBreakdown + { + Seq = seq++, + Type = "Cohort Set", + Name = CleanName(agg.Name), + Container = CleanName(container.Name), + SetOperation = "", + DisplayOrder = agg.Order, + FinalUnfiltered = aFinal, + CumulativeUnfiltered = i > 0 ? aCum : null, + FinalByRegion = RunRegionCounts(CachedSetSql(agg)), + CumulativeByRegion = aCumByRegion + }); + break; + + case CohortAggregateContainer sub: + Walk(sub, container, i, nodes, ref seq); + break; + } + } + } + + // --- identifier-list SQL composed purely from the per-set cache tables --- + + private string IdSql(IOrderable node) => node switch + { + AggregateConfiguration agg => CachedSetSql(agg), + CohortAggregateContainer c => Compose(c, EnabledOrdered(c)), + _ => throw new System.NotSupportedException(node.GetType().Name) + }; + + private string CumulativeSql(CohortAggregateContainer container, int upToInclusive) => + Compose(container, EnabledOrdered(container).Take(upToInclusive + 1).ToList()); + + private string Compose(CohortAggregateContainer container, IReadOnlyList children) + { + var op = $"\n{container.Operation}\n"; // UNION / INTERSECT / EXCEPT are valid SQL Server keywords + return string.Join(op, children.Select(ch => $"({IdSql(ch)})")); + } + + private string CachedSetSql(AggregateConfiguration agg) + { + if (!_setCacheTable.TryGetValue(agg.ID, out var t)) + { + var table = _cacheManager.GetLatestResultsTableUnsafe(agg, + AggregateOperation.IndexedExtractionIdentifierList) as DiscoveredTable; + if (table == null) + throw new System.Exception($"Cohort set '{agg.Name}' has no cached identifier list - the build did not cache it"); + var col = table.DiscoverColumns()[0].GetRuntimeName(); + t = (table.GetFullyQualifiedName(), col); + _setCacheTable[agg.ID] = t; + } + + return $"SELECT {t.col} AS id FROM {t.fqn}"; + } + + private List EnabledOrdered(CohortAggregateContainer container) => + container.GetOrderedContents().Where(o => o switch + { + AggregateConfiguration a => !a.IsDisabled, + CohortAggregateContainer c => !c.IsDisabled, + _ => true + }).ToList(); + + // --- run a GROUP BY Region join (on the cache server) for one count point --- + + private IReadOnlyDictionary RunRegionCounts(string idListSql) + { + var sql = + $"SELECT d.[{_regionName}] AS Region, COUNT(DISTINCT i.id) AS n\n" + + $"FROM (\n{idListSql}\n) i\n" + + $"INNER JOIN {_demogTable} d ON d.[{_demogId}] = i.id\n" + + $"GROUP BY d.[{_regionName}]"; + + var result = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + using var con = _cacheDb.Server.GetConnection(); + con.Open(); + using var cmd = _cacheDb.Server.GetCommand(sql, con); + cmd.CommandTimeout = _timeout; + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + if (r["Region"] == System.DBNull.Value) + continue; // NULL region folds into Unknown via baseline subtraction + result[r["Region"].ToString()] = System.Convert.ToInt32(r["n"]); + } + + return result; + } + + // RDMP prefixes cohort set names with "cic__" (EnsureNamingConvention); cloning a cohort across + // CICs stacks them (e.g. cic_18286_cic_18284_cic_17950_People in SHARE...). Strip them for display. + private static readonly Regex CicPrefix = new(@"^(cic_\d+_)+", RegexOptions.Compiled); + + public static string CleanName(string name) => string.IsNullOrEmpty(name) ? "" : CicPrefix.Replace(name, ""); + + private static string Sanitise(string name) + { + foreach (var c in Path.GetInvalidFileNameChars()) + name = name.Replace(c, '_'); + return name; + } +} diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/HealthBoardLookup.cs b/RdmpCohortBuildHealthBoardBreakdown/src/HealthBoardLookup.cs new file mode 100644 index 0000000000..a0a2a67ff5 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/src/HealthBoardLookup.cs @@ -0,0 +1,61 @@ +// Copyright (c) The University of Dundee 2024-2024 +// This file is part of the Research Data Management Platform (RDMP). +// RDMP is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// RDMP is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with RDMP. If not, see . + +using System.Collections.Generic; + +namespace Rdmp.Core.CohortCreation; + +/// +/// A Scottish health board: the single-letter cipher held in +/// SHARE_Demography, its numeric (null for legacy boards), its display +/// , and the safe-haven it rolls up to. +/// +public sealed record HealthBoard(string Region, int? HbCode, string Name, string Node); + +/// +/// Hardcoded mapping from a SHARE_Demography Region cipher to its health board and +/// safe-haven node. Single source of truth for the cohort health-board breakdown report; an +/// unrecognised or NULL region resolves to a non-null "(unknown)" board under the +/// so counts are never silently dropped. +/// +public static class HealthBoardLookup +{ + /// Node assigned to any region cipher not present in the lookup (or NULL/empty). + public const string UnknownNode = "Unknown"; + + // keyed by the single-letter Region cipher held in SHARE_Demography.Region + private static readonly Dictionary ByRegion = new(System.StringComparer.OrdinalIgnoreCase) + { + ["A"] = new("A", 11, "Ayrshire & Arran", "West"), + ["B"] = new("B", 6, "Borders", "South East"), + ["Y"] = new("Y", 12, "Dumfries & Galloway", "West"), + ["F"] = new("F", 4, "Fife", "East"), + ["V"] = new("V", 7, "Forth Valley", "East"), + ["N"] = new("N", 2, "Grampian", "North"), + ["G"] = new("G", 16, "Greater Glasgow & Clyde", "West"), + ["H"] = new("H", 17, "Highland", "North"), + ["L"] = new("L", 10, "Lanarkshire", "West"), + ["S"] = new("S", 5, "Lothian", "South East"), + ["R"] = new("R", 13, "Orkney", "North"), + ["Z"] = new("Z", 14, "Shetland", "North"), + ["T"] = new("T", 3, "Tayside", "East"), + ["W"] = new("W", 15, "Western Isles", "North"), + ["C"] = new("C", null, "Clyde", "West"), // legacy board: no numeric HB_Code (intentional) + }; + + /// + /// Resolves a Region cipher to its . Unknown, NULL or empty + /// ciphers map to a placeholder board (name "(unknown)", node ) + /// rather than null, so unmapped patients are reported and reconcile to the cohort total. + /// + public static HealthBoard Resolve(string region) + { + var key = region?.Trim(); + return !string.IsNullOrEmpty(key) && ByRegion.TryGetValue(key, out var hb) + ? hb + : new HealthBoard(key ?? "", null, "(unknown)", UnknownNode); + } +} diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.csproj b/RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.csproj new file mode 100644 index 0000000000..c9a7da7e32 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + enable + disable + RdmpCohortBuildHealthBoardBreakdown + false + + false + + + + + + false + + + + diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.nuspec b/RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.nuspec new file mode 100644 index 0000000000..73e4c581c6 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.nuspec @@ -0,0 +1,17 @@ + + + + RdmpCohortBuildHealthBoardBreakdown + 0.0.1 + HIC + Reproduces the Cohort Builder's per-set / per-container count tree (FinalCount and + cumulative running totals) split by Scottish health board, working purely on the query cache: + builds the cohort once, then recomposes every count point from the cached per-set tables and + splits it by SHARE_Demography.Region. CLI: ExportCohortBuildHealthBoardBreakdown; GUI: + right-click a cohort identification configuration. + + + + + + From 604541f167a60d8b78b87dcf6e334c48ab303e92 Mon Sep 17 00:00:00 2001 From: mtinti Date: Wed, 1 Jul 2026 15:41:23 +0100 Subject: [PATCH 02/16] Remove docs/ from the plugin package; trim README references Keep the package to the built .rdmp + INSTALL + README + source. Design and technical docs remain on the proposals branch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0169JCnaL3fhhZjseDx2XXT2 --- RdmpCohortBuildHealthBoardBreakdown/README.md | 10 +- .../docs/BUILD-BREAKDOWN-FEASIBILITY.md | 179 -------------- .../docs/BUILD-BREAKDOWN-PLAN.md | 145 ----------- .../docs/BUILD-BREAKDOWN-TEST-FIXTURE.md | 87 ------- .../docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md | 94 ------- .../docs/TECHNICAL-BACKGROUND.html | 230 ------------------ 6 files changed, 2 insertions(+), 743 deletions(-) delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-FEASIBILITY.md delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-PLAN.md delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-TEST-FIXTURE.md delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/docs/TECHNICAL-BACKGROUND.html diff --git a/RdmpCohortBuildHealthBoardBreakdown/README.md b/RdmpCohortBuildHealthBoardBreakdown/README.md index 8d860aad02..4a15197975 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/README.md +++ b/RdmpCohortBuildHealthBoardBreakdown/README.md @@ -4,8 +4,7 @@ Reproduces the Cohort Builder's per-set / per-container count tree (the `FinalCo running totals shown as UNION/INTERSECT/EXCEPT are applied) **split by Scottish health board**, plus an unfiltered national total, and writes it to a wide CSV. -This folder is a self-contained package: the ready-to-install plugin, install/usage notes, the source, -and the design + technical documentation. +This folder is a self-contained package: the ready-to-install plugin, install/usage notes, and the source. ## Contents @@ -14,11 +13,6 @@ and the design + technical documentation. | `RdmpCohortBuildHealthBoardBreakdown.rdmp` | the built plugin (drop into RDMP / add via the Plugins node) | | `INSTALL.md` | install + usage (GUI right-click and CLI) | | `src/` | plugin source (command, report, health-board lookup, UI hook, csproj, nuspec) | -| `docs/TECHNICAL-BACKGROUND.html` | high-level walkthrough — what runs at each step, real code + SQL | -| `docs/BUILD-BREAKDOWN-FEASIBILITY.md` | feasibility + the distributivity / cache-only rationale | -| `docs/BUILD-BREAKDOWN-PLAN.md` | implementation plan (as built) | -| `docs/BUILD-BREAKDOWN-TEST-FIXTURE.md` | the deterministic synthetic validation fixture | -| `docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md` | the wide-report layout decisions | ## How it works (in one paragraph) @@ -40,7 +34,7 @@ above a `% of final cohort` row. Boards + Other + NotKnown reconcile to Total on Verified end-to-end against a deterministic synthetic fixture (top EXCEPT over an inclusion INTERSECT minus four exclusion sets, the cohort partitioned across 3 boards): every national and per-board `FinalCount` / cumulative is asserted cell-by-cell, the unfiltered column equals RDMP's own -`CohortCompiler` counts, and the boards sum to national at every node. See `docs/BUILD-BREAKDOWN-TEST-FIXTURE.md`. +`CohortCompiler` counts, and the boards sum to national at every node. ## Build from source (optional) diff --git a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-FEASIBILITY.md b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-FEASIBILITY.md deleted file mode 100644 index ed4dfe5646..0000000000 --- a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-FEASIBILITY.md +++ /dev/null @@ -1,179 +0,0 @@ -# Feasibility — per-health-board cohort *build* breakdown (UI count tree × health board) - -Discussion doc. Extends the committed health-board breakdown (`FEASIBILITY.md` / `PLAN.md`, -final-list counts only) to reproduce the **Cohort Builder's whole count tree** — the per-set and -per-container *total* and *cumulative running total* shown as UNION/INTERSECT/EXCEPT are applied — -**once per health board**, plus the unfiltered baseline. Goal: "see how the number shrinks through -the tree, for each board." Saved to file. - -## TL;DR - -Feasible and **cheap**, because (a) board-restriction distributes over the set operations, so each -per-board number is just the unrestricted identifier set at that point ∩ board; and (b) RDMP already -caches each cohort set's identifier list as a queryable table. So we **build once** (populating the -cache) and then run **one `GROUP BY Region` query per count point** — every board in that one query. -No per-board rebuild, no source-DB hits beyond the single baseline build. - ---- - -## 1. The maths that removes the N-rebuild cost - -Restricting the cohort to board `H` is intersection with a fixed patient set, and `∩ H` distributes -over every container operation: - -``` -(A ∪ B) ∩ H = (A∩H) ∪ (B∩H) -(A ∩ B) ∩ H = (A∩H) ∩ (B∩H) -(A \ B) ∩ H = (A∩H) \ (B∩H) (EXCEPT too; order preserved) -``` - -Therefore the count RDMP shows at **any** node (a set total, a container total, or a -cumulative-up-to-child-k) equals `(unrestricted identifier set at that node) ∩ H`. We never have to -re-run the build under a board filter — we take the unrestricted identifier list at each node and -split it by `Region`. - -(Relies on board membership being a per-patient property, 1 board ↔ 1 patient — already confirmed — -and the board filter being a pure post-hoc intersection, not something that changes a set's internal -logic. Both hold.) - -## 2. What RDMP caches (verified in source) - -- **Cached, queryable:** each cohort *set* (`AggregateConfiguration`) → an indexed single-column - identifier table `IndexedExtractionIdentifierList_AggregateConfiguration` in the query-cache - DB; patient-index tables → `JoinableInceptionQuery_...`. Fetch via - `CachedAggregateConfigurationResultsManager.GetLatestResultsTable(agg, IndexedExtractionIdentifierList, sql)` - → fully-qualified table name. (The set's cached list is its FINAL list — post-filters, post-PIT-join.) -- **NOT cached:** container totals, cumulative/running totals. `AggregationContainerTask` is not a - `CacheableTask`; cumulative is computed by a throwaway `CohortQueryBuilder` over the parent - container with `StopContainerWhenYouReach = childK`, run only to count rows in memory, then discarded. -- Counts in the UI are `DataTable.Rows.Count` of the pulled identifier list (not SQL `COUNT`). - `FinalRowCount` = the node's own count; `CumulativeRowCount` = running total within its container - (null for the first child / when cumulative totals were off). - -**Consequence:** the cache gives us exactly the per-set identifier tables. Container/cumulative points -must be *recomposed* — but RDMP will generate that composition SQL for us (reading from cache), or we -recompose in memory. Either way the expensive source queries run once (the baseline build). - -## 3. Count points to reproduce (mirror the UI exactly) - -Walk `CohortAggregateContainer.GetOrderedContents()` recursively (respect `Order`, `Operation`, -skip disabled), and for each container emit: -- one **set total** row per child set (`FinalRowCount`), -- one **cumulative** row per non-first child (`CumulativeRowCount` = container up to & incl. child k), -- one **container total** row. - -This is the same enumeration the existing `CohortCountReport` produces; we reuse its row shape and add -a board dimension. - -## 4. Two implementations (both cache-leveraged; recommend A) - -### A. Server-side recompose via RDMP's own query builder (recommended) -For each count point, ask RDMP for its identifier-list SQL — it already splices in the cache tables: -- set total → `CohortQueryBuilder(aggregate, globals, childProvider)` -- container total → `CohortQueryBuilder(container, globals, childProvider)` -- cumulative k → `CohortQueryBuilder(parentContainer, …){ StopContainerWhenYouReach = childK }` - -Then wrap (params hoisted exactly like the committed-cohort command already does): -```sql -SELECT d.Region, COUNT(DISTINCT i.id) AS n -FROM ( ) i -JOIN SHARE_Demography d ON d.chi = i.id -GROUP BY d.Region -``` -- **One query per count point, all boards at once.** ~`2·sets + containers` queries total (tens, not - hundreds) — independent of board count. -- **Fidelity:** uses RDMP's exact composition SQL, so it can't drift from the UI semantics - (order/EXCEPT/disabled/PITs all handled by RDMP). -- **New code is small:** tree walk + the `GROUP BY Region` wrapper + assembling the matrix. -- **Requirement:** the query-cache DB and `SHARE_Demography` must be co-queryable (same server, or - 3-part/linked). Needs confirming (the final-list feature already assumes same server for demography). - -### B. Client-side recompose (fallback / no cross-server) -Fetch each set's identifiers once with Region attached (`SELECT t.id, d.Region FROM t JOIN -SHARE_Demography d …`), then replay the container set-algebra in memory per board (hash sets), mirroring -`CohortCompiler`. Produces every total + cumulative for every board and the unfiltered baseline in one -pass, **no cross-server join**. Cost: pulls all set identifiers to the client (heavy for very large -cohorts). Good fallback when cache and demography live on different servers. - -> Recommendation: **A** for fidelity + scale; keep **B** as the fallback when cache/demography aren't -> co-located. Both avoid the N-board rebuild. - -## 5. Build-once + cache - -1. Ensure the CIC has a `QueryCachingServer` and run `CohortCompilerRunner` once with - `IncludeCumulativeTotals = true`. This (a) populates every per-set cache table and (b) gives the - **baseline** `FinalRowCount`/`CumulativeRowCount` per node straight from RDMP. -2. If the cache is already fresh (user built it in the UI), step 1 is a no-op fast path — we can read - the cache tables directly without re-running source queries. -3. All per-board work in §4 then reads only the cache (+ demography), never the source databases. - -## 6. Built-in correctness check - -The **unfiltered** column must equal RDMP's own `FinalRowCount`/`CumulativeRowCount` from the baseline -build, and the per-board counts (+ an `Unknown`/not-in-demography bucket) must **sum to the unfiltered** -at every node. Both are cheap asserts that catch any composition/order mistake automatically. - -## 7. Output options (for discussion) - -Rows = count points in tree order (Order, Type, Name, Container, SetOperation). Then either: -- **Long:** add `Board`, `Node`, `FinalCount`, `CumulativeCount` columns (one row per count-point × - board). Most flexible; easy to pivot. ← suggested default. -- **Wide:** a `FinalCount`/`CumulativeCount` pair of columns per board. Closest to "the UI table with a - column per board" but wide and awkward with ~15 boards × 2. -- **One file per board** (+ an `_unfiltered` file): each is exactly today's `CohortCountReport` CSV. - -All reuse `HealthBoardLookup` (Region→board/node) and the `Unknown` bucket from the existing feature. - -## 8. Scope / caveats - -- **CIC-only.** This needs the build tree; a committed `ExtractableCohort` has no tree (final-list - breakdown already covers that case). -- Ships as a second command in the existing `RdmpHealthBoardBreakdown` plugin (e.g. - `ExportCohortBuildHealthBoardBreakdown`), reusing the demography resolution, param-hoisting, CSV and - `HealthBoardLookup` already written. -- Cross-server (approach A) and cache-presence are the two real requirements — both checkable up front - with a clear `SetImpossible` message. -- Disabled sets/containers and patient-index tables: handled for free in A (RDMP's SQL); must be - replicated in B. - -## 9. Effort (rough) - -- Tree walk + count-point enumeration (reuse `CohortCountReport` shape): ~0.5 day -- Approach A wrapper + param hoist + run/collect + matrix assembly: ~1 day -- Baseline build + reconciliation asserts: ~0.5 day -- No-DB unit tests (composition/ordering/Unknown) + a docker DB end-to-end (small CIC with a cache, - EXCEPT over INTERSECT, assert unfiltered == RDMP and boards sum to total): ~1 day -- Approach B fallback (optional): ~1 day -- Plugin wiring + 9.2.3 build (pipeline already exists): ~0.5 day - -**≈ 3–3.5 days** (A only), +1 day for the B fallback. - -## 10. Decisions (locked 2026-06-25) - -1. **Approach A only** (server-side recompose via `CohortQueryBuilder`, reading the cache). No client-side - B fallback — the cohort spans servers, so the cache is the single consolidation point and we operate - purely on it. -2. **New, separate plugin** (`RdmpCohortBuildHealthBoardBreakdown`), not an addition to the existing - final-list plugin. Reuses `HealthBoardLookup` + the demography-resolution / param-hoist / CSV patterns - by copying them in (plugin must be self-contained for the 9.2.3 build). -3. **Output = long format, grouped clearly by health board.** One row per (board × count-point), columns: - `Board, Node, Order, Type, Name, Container, SetOperation, FinalCount, CumulativeCount`. Rows ordered - board-major (all of board T's tree, then board G's, …), with an `Unfiltered` pseudo-board first and an - `Unknown` board last so each node reconciles. -4. **Cache is REQUIRED.** Catalogues are on different servers, so without a populated query cache the - composition can't run. `SetImpossible` if the CIC has no `QueryCachingServer` or the per-set caches - are missing/stale (offer to run one baseline build to populate). -5. **Cross-server demography:** the recompose + `GROUP BY Region` join runs on the **cache server**, so - `SHARE_Demography` must be reachable from there. The plugin checks `QueryCachingServer.Server` == - `SHARE_Demography` `TableInfo.Server` at construction and `SetImpossible`s with a clear message if not. - (Likely same server, but verified at runtime — see note below.) -6. **Cumulative semantics:** reproduce RDMP's "cumulative within container, from the 2nd child" exactly, - by using RDMP's own `StopContainerWhenYouReach` query (guarantees parity with the UI). -7. Boards come only from `SHARE_Demography.Region` (no per-board published filters needed for counting). - -> **Co-location note:** the first (final-list) plugin proved `SHARE_Demography` is on the same server as -> the cohort/data store; it did NOT exercise the query-cache server (a separate `ExternalDatabaseServer`). -> So co-location of cache + demography is *probable* but not proven from that work — hence the runtime -> check in decision 5. If they turn out to be on different servers, the mitigation is to materialise a -> small `(chi, Region)` tag table on the cache server once per run and join to that instead (out of scope -> unless the check fails). diff --git a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-PLAN.md b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-PLAN.md deleted file mode 100644 index 5f8b15195a..0000000000 --- a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-PLAN.md +++ /dev/null @@ -1,145 +0,0 @@ -# Implementation plan — per-health-board cohort *build* breakdown (cache-only, approach A) - -Companion to `BUILD-BREAKDOWN-FEASIBILITY.md` (decisions locked in §10 there). Reproduces the Cohort -Builder's per-set / per-container *total* + *cumulative* count tree, once per health board + an -unfiltered baseline, operating purely on the query cache, saved as a long-format CSV. Ships as a NEW, -self-contained plugin `RdmpCohortBuildHealthBoardBreakdown`. CIC-only. - -## 0. Strategy - -Develop + test in `Rdmp.Core` first (so the docker NUnit harness can exercise it, like the final-list -feature), then copy into the new plugin and build the 9.2.3 `.rdmp`. Reuses `HealthBoardLookup` and the -param-hoist / CSV / demography-resolution patterns from the final-list work. - -## 1. Files - -**Core (dev + test):** -- `Rdmp.Core/CohortCreation/CohortBuildHealthBoardBreakdownReport.cs` — long-format projection → CSV. -- `Rdmp.Core/CommandExecution/AtomicCommands/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs` - — the command (CIC input). -- (reuses existing `Rdmp.Core/CohortCreation/HealthBoardLookup.cs`.) - -**Tests:** `Rdmp.Core.Tests/CohortCreation/CohortBuildHealthBoardBreakdownTests.cs`. - -**New plugin (phase 2):** `proposals/cohort-healthboard-breakdown/build-plugin/` — -`RdmpCohortBuildHealthBoardBreakdown.csproj`/`.nuspec`, a `PluginUserInterface` (right-click a CIC), -plus copies of `HealthBoardLookup.cs`, the report and the command. - -## 2. Command flow (`Execute`) - -``` -ExecuteCommandExportCohortBuildHealthBoardBreakdown( - IBasicActivateItems activator, - CohortIdentificationConfiguration cic, - FileInfo toFile = null, // -build-healthboard.csv - string demographyCatalogue = "SHARE_Demography", - string regionColumn = "Region", - int timeout = 5000) -``` - -Construction-time `SetImpossible` guards: -- `cic` null or no root container. -- `cic.QueryCachingServer == null` → "needs a query caching server (cohort spans servers)". -- demography catalogue / `Region` / IsExtractionIdentifier column missing (same resolution as final-list). -- **co-location:** `cic.QueryCachingServer.Server` != `SHARE_Demography` `TableInfo.Server` → impossible - with a clear message (the recompose+join runs on the cache server). - -Execute: -1. **Build once / refresh cache + baseline.** `compiler = new CohortCompiler(activator, cic){ - IncludeCumulativeTotals = true }; new CohortCompilerRunner(compiler, timeout){ RunSubcontainers = - true }.Run(token)`. This populates every per-set cache table and gives the baseline - `FinalRowCount` / `CumulativeRowCount` per node (our Unfiltered column + reconciliation source). - If any set crashes, surface it (don't emit a wrong tree). -2. **Enumerate count points** by walking `cic.RootCohortAggregateContainer` recursively via - `GetOrderedContents()` (respect `Order`, `Operation`, skip `IDisabled`). For each container, in order: - for each child a *set-total* (sets) or *container-total* (sub-containers) point, plus a *cumulative* - point for every non-first enabled child. Carry (Order, Type, Name, ContainerName, SetOperation) — the - same shape as `CohortCountReport`. -3. **Per count point, build the identifier-list SQL directly from the cache tables (AS BUILT).** Every - node is recomposed by hand from the per-set cache tables — `CohortQueryBuilder` is NOT used for the - recompose (only `CohortCompiler` runs once, to populate the cache + give the baseline). This avoids - parameter hoisting entirely (cache tables are bare identifier lists) and guarantees cache-server-only - SQL. - - set cache table: `CachedAggregateConfigurationResultsManager.GetLatestResultsTableUnsafe(agg, - IndexedExtractionIdentifierList)` → `SELECT AS id FROM ` (`CachedSetSql`). - - set total → that set SQL. - - container total → `Compose(container, enabledOrderedChildren)` = `(child0) (child1) ...` with - `` = the container's UNION/INTERSECT/EXCEPT, each arm recursing into `IdSql`. - - cumulative k → `Compose(parentContainer, children.Take(k+1))`. - (No globals / no params needed — the arms are `SELECT id FROM `.) -4. **Split by board in one query per count point:** then - ```sql - SELECT d.[Region] AS Region, COUNT(DISTINCT i.id) AS n - FROM ( ) i - JOIN d ON d.chi = i.id - GROUP BY d.[Region] - ``` - run on the **cache server** (`DataAccessPortal.ExpectDatabase(cacheServer DB)`). One query → all boards. -5. **Assemble long rows.** For each count point: emit one row per board present (mapped via - `HealthBoardLookup`), an `Unknown` row = baseline count − Σ known boards (patients not in demography / - unmapped region), and an `Unfiltered` row = baseline count. `CumulativeCount` filled the same way from - the cumulative query (null where RDMP's cumulative is null — first child / container totals as RDMP does). -6. **Reconcile (assert + report):** for every count point, Σ board FinalCount (+Unknown) must equal the - baseline `FinalRowCount`; Unfiltered must equal baseline. Same for cumulative. Mismatch → warn loudly in - the summary. -7. Write CSV; `BasicActivator.Show` a summary (nodes, boards, any reconciliation drift). - -## 3. Output (long format, board-grouped) - -Columns: `Board, Node, Order, Type, Name, Container, SetOperation, FinalCount, CumulativeCount`. -Row order: **board-major** — `Unfiltered` block first (the exact UI tree), then each board T, G, … each a -full tree, then `Unknown` last. Within a board, rows follow the tree `Order` (so it reads like the UI count -table repeated per board). Reuses the `CohortCountReport` CSV escaper. - -## 4. Why this is cache-only and cross-server-safe - -- Every node (set, container, cumulative) is recomposed by hand from the per-set cache tables, so all - recompose SQL references only cache-server objects. -- The only non-cache object touched is `SHARE_Demography`, required (by the co-location check) to be on the - cache server, so every query is single-server. The source catalogue servers are never touched after step 1 - (the one `CohortCompiler` build). - -## 5. Tests (docker) - -**No-DB:** report projection (long format, board-major ordering, Unknown/Unfiltered rows, reconciliation -helper); the GROUP-BY-Region wrapper string builder. - -**DB end-to-end (`DatabaseTests`, model on `CohortQueryBuilderWithCacheTests` + the final-list DB test):** -build 2–3 small synthetic catalogues with data + a `SHARE_Demography(chi,Region)` table on the docker -server; set the CIC's `QueryCachingServer` to `TEST_QueryCache`; build an EXCEPT-over-INTERSECT CIC; run the -command. Assert: -- Unfiltered Final/Cumulative per node == values from a direct `CohortCompiler` run (parity with UI). -- Per-board + Unknown sums == Unfiltered at every node (reconciliation). -- A hand-computed board (e.g. all cohort members in Tayside except one) matches at the leaf and after the - EXCEPT (proves distributivity through the tree). -Run: `bash mac-test-env/run-tests.sh "FullyQualifiedName~CohortBuildHealthBoard"`. - -> Note: the Mac+docker SQL TLS limitation blocks a full *CLI* data run (as before); execution correctness -> is proven by these NUnit DB tests through the real query cache + CohortCompiler stack. - -## 6. Plugin + 9.2.3 + upload (phase 2) - -- New plugin `RdmpCohortBuildHealthBoardBreakdown` (right-click a CIC). Self-contained (copies of the 3 - sources). Built against the v9.2.3 worktree (core lacks the classes → no CS0433), packaged `.rdmp`, - verified to load (ListSupportedCommands shows `ExportCohortBuildHealthBoardBreakdown`). -- Upload to a **new** OneDrive folder `onedrive:rdmp/healthboard_build_breakdown/` + INSTALL.md, byte-verified. - -## 7. Effort - -Core command + report + tree walk + per-point query: ~1.5 days. Tests (no-DB + docker cache E2E): ~1 day. -Plugin + 9.2.3 + upload (pipeline exists): ~0.5 day. **≈ 3 days.** - -## 8. Checklist - -- [x] `CohortBuildHealthBoardBreakdownReport` (long CSV) + no-DB tests -- [x] count-point tree walk (parity with `CohortCountReport` enumeration) -- [x] command: build-once + co-location/cache guards + per-point GROUP BY Region on cache server -- [x] reconciliation asserts (Unfiltered == RDMP; boards+Unknown == Unfiltered) -- [x] docker DB end-to-end (cache + EXCEPT/INTERSECT) green - (`CohortBuildHealthBoardBreakdownTests` — the fixture in BUILD-BREAKDOWN-TEST-FIXTURE.md: - national 100→80→65→60→58, Tayside 50→40→32→30→29, Glasgow→17, Fife→12, Unknown=20, - INTERSECT cumulative=100, partition-sums-to-national at every node; passes) -- [x] new plugin + UI hook + 9.2.3 `.rdmp` load-verified - (`RdmpCohortBuildHealthBoardBreakdown` — right-click a CIC; built vs vanilla v9.2.3 worktree; - `.rdmp` loads `ExportCohortBuildHealthBoardBreakdown` into the 9.2.3 CLI, absent without it) -- [x] upload to onedrive:rdmp/healthboard_build_breakdown/ (byte-verified, 15302 bytes) + INSTALL.md diff --git a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-TEST-FIXTURE.md b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-TEST-FIXTURE.md deleted file mode 100644 index fd9c0031a6..0000000000 --- a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-TEST-FIXTURE.md +++ /dev/null @@ -1,87 +0,0 @@ -# Validation fixture — per-health-board build breakdown (docker, synthetic) - -Fully deterministic. 1 patient ↔ 1 board, so the 3 boards **partition** the cohort and sum to the -national number at *every* node (set total and cumulative). Exercises INTERSECT, EXCEPT, cumulative -through the tree, and the "no region" Unknown bucket. - -## People (120 ids: P001–P120) - -| Group | IDs | Count | Region | -|---|---|---|---| -| Tayside | P001–P050 | 50 | T | -| Glasgow | P051–P080 | 30 | G | -| Fife | P081–P100 | 20 | F | -| Registry-only (no demography row) | P101–P120 | 20 | — (Unknown) | - -## Catalogues / tables (all on one docker server) - -- **BB_Demography** (`chi, Region`): P001–P100 with their Region (the only place Region lives; the - reference table). 100 rows. -- **BB_Registry** (`chi`): P001–P120 (the cohort source; includes 20 people with no demography row). 120 rows. -- **BB_Excl1..4** (`chi`): disjoint exclusion subsets, all within P001–P100 (each = exactly the people it removes): - -| Excl | Tayside | Glasgow | Fife | Total | -|---|---|---|---|---| -| Excl1 | P001–P010 (10) | P051–P056 (6) | P081–P084 (4) | 20 | -| Excl2 | P011–P018 (8) | P057–P060 (4) | P085–P087 (3) | 15 | -| Excl3 | P019–P020 (2) | P061–P062 (2) | P088 (1) | 5 | -| Excl4 | P021 (1) | P063 (1) | — (0) | 2 | - -## CIC structure - -``` -ROOT (EXCEPT) -├─ Inclusion (INTERSECT) -│ ├─ BB_Registry set total 120 -│ └─ BB_Demography set total 100 ⇒ Inclusion = 100 -├─ BB_Excl1 set total 20 -├─ BB_Excl2 set total 15 -├─ BB_Excl3 set total 5 -└─ BB_Excl4 set total 2 ⇒ ROOT (national cohort) = 58 -``` - -Child order matters (EXCEPT/cumulative): Inclusion first, then Excl1..4 in order. - -## Expected count tree (what the plugin must reproduce) - -`FinalCount` = the node's own set/container count. `CumulativeCount` = running total within the -container (null for the first child, per the UI). - -### Inclusion container (INTERSECT) -| Node | Final (Nat) | Cum (Nat) | T | G | F | Unknown | -|---|---|---|---|---|---|---| -| BB_Registry (set) | 120 | — | 50 | 30 | 20 | **20** | -| BB_Demography (set) | 100 | 100 | 50 | 30 | 20 | 0 | -| Inclusion (total) | 100 | — | 50 | 30 | 20 | 0 | - -(The 20 registry-only people show up under Unknown on the BB_Registry set, then the INTERSECT with -demography drops them — demonstrating the Unknown bucket and that no-region people don't leak.) - -### ROOT container (EXCEPT) — cumulative is the key check -| Node | Final (Nat) | Cum (Nat) | Cum T | Cum G | Cum F | -|---|---|---|---|---|---| -| Inclusion (child 0) | 100 | — | 50 | 30 | 20 | -| BB_Excl1 | 20 | **80** | 40 | 24 | 16 | -| BB_Excl2 | 15 | **65** | 32 | 20 | 13 | -| BB_Excl3 | 5 | **60** | 30 | 18 | 12 | -| BB_Excl4 | 2 | **58** | 29 | 17 | 12 | -| ROOT (total) | 58 | — | 29 | 17 | 12 | - -National cumulative: 100 → 80 → 65 → 60 → 58. -Per-board cumulative diverges (Tayside −21, Glasgow −13, Fife −8) and **T+G+F = national at every row** -(40+24+16=80, 32+20+13=65, 30+18+12=60, 29+17+12=58). That cross-check is the automated assertion. - -## What this validates - -- INTERSECT (120 ∩ 100 = 100) and EXCEPT cumulative down the tree. -- Per-board cumulative correctness via distributivity (boards partition → sum to national everywhere). -- The Unknown / not-in-demography bucket (the 20 registry-only ids). -- The unfiltered column equals RDMP's own `CohortCompiler` counts (separate assertion). - -## Test mechanics (docker) - -`DatabaseTests` fixture: create the 6 tables with the data above on the docker server, import as -catalogues, set BB_Demography.chi + BB_Registry.chi + each Excl.chi as IsExtractionIdentifier, set -BB_Demography.Region; build the CIC tree above; set `QueryCachingServer = TEST_QueryCache`; run the -command; assert the full table above (national + T/G/F + Unknown) cell-by-cell, and that every node's -T+G+F(+Unknown) sums to its national value. diff --git a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md b/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md deleted file mode 100644 index 4d74ea6114..0000000000 --- a/RdmpCohortBuildHealthBoardBreakdown/docs/BUILD-BREAKDOWN-WIDE-REPORT-PLAN.md +++ /dev/null @@ -1,94 +0,0 @@ -# Plan — wide ("horizontal") build-breakdown report - -Changes the cohort-build health-board breakdown output from the current **long** format (one row per -count-point × board) to a **wide/horizontal** matrix: one row per count point with the container/set -name once, a `Total` column, one column per health board, plus an explicit split of the old catch-all -`Unknown`. Adds a bottom **% contribution** block and keeps the **breakdown-sums-to-national** check. - -Status: PLAN (not yet implemented). Affects only `CohortBuildHealthBoardBreakdownReport` (the -projection/CSV) and a few assertions; the cache-only recompose engine in the command is unchanged. - -## 1. Layout - -One row per count point (tree order), name written once. Columns: - -``` -Order | Type | Name | Container | SetOperation | Metric | Total | | Other | NotKnown -``` - -- `Total` — the national (non-breakdown) number for that node = RDMP's own count. Kept as a column. -- `` — one column per Scottish health board (the 15 from HealthBoardLookup; only those - that appear, ordered by node then name). -- `Other` — patients whose region code IS present in demography but is **not** one of the 15 Scottish - boards (non-Scottish / unmapped codes). NEW — split out of the old Unknown so non-Scottish boards are - visible. (Optionally each distinct other code as its own column — see §4 decision.) -- `NotKnown` — residual = `Total − Σ(boards) − Other` = patients **not in demography** + **NULL region**. - This is the "we genuinely can't place them" bucket. -- `Metric` — `Final` or `Cumulative` (see §4 decision on whether we keep both). - -Every data row satisfies: `Σ(boards) + Other + NotKnown == Total`. - -### Sample (national cohort = 58, fixture numbers) - -``` -Order Type Name Container SetOp Metric Total Tayside Glasgow Fife Other NotKnown -0 Container Root EXCEPT Cumulative 58 29 17 12 0 0 -1 Container Inclusion Root INTER Final 100 50 30 20 0 0 -2 Cohort Set Registry Inclusion Final 120 50 30 20 0 20 -... -``` - -## 2. Bottom block — % contribution to the total - -After the data rows, a separator then a `% of final cohort` section: for the **final cohort** (the root -node), each board's share = `board / Total × 100`. One row: - -``` -% of final cohort 100.0 50.0 29.3 20.7 0.0 0.0 -``` - -(Tayside 29/58 = 50.0%, Glasgow 17/58 = 29.3%, Fife 12/58 = 20.7%.) Percentages computed from the -chosen Metric's root row. Option to also emit a per-row `%` block (each node's board split) — see §4. - -## 3. The "Unknown" split (answers the non-Scottish question) - -Old behaviour: `Unknown = Total − Σ(15 boards)` — merged non-Scottish codes + NULL region + not-in- -demography into one number. New behaviour, using data we already fetch (the GROUP BY returns every -present code): - -| Bucket | Definition | Source | -|---|---|---| -| board columns | the 15 Scottish ciphers | GROUP BY rows where `HealthBoardLookup.Resolve(code)` is a real board | -| `Other` | present region codes NOT in the 15 (non-Scottish / unmapped) | GROUP BY rows where Resolve → Unknown node | -| `NotKnown` | `Total − Σ(boards) − Other` | residual = not-in-demography + NULL region | - -So non-Scottish boards are no longer hidden — they land in `Other` (or their own columns, §4), and -`NotKnown` becomes a clean "no usable location" figure. (NULL region stays inside `NotKnown`; splitting -NULL from not-in-demography is possible but low value — noted, not planned.) - -## 4. Reconciliation check (kept + strengthened) - -Keep "breakdown sums to the national non-breakdown search": -- `Total` per node already = RDMP's `CohortCompiler` count (the non-breakdown national number). -- New genuine check: `Σ(boards) + Other` is computed from the independent GROUP BY query; assert it is - `≤ Total`, and define `NotKnown = Total − that` (so the row always reconciles, and a negative - `NotKnown` would flag a key/join bug). The DB test additionally asserts every cell against the - hand-derived fixture, so the equality is a real check, not a tautology. - -## 5. Decisions (confirmed 2026-06-27) - -1. **Metric = Both** — a `Metric` column; two rows per node (`Final` then `Cumulative`, the latter only - where RDMP has a cumulative). Node name repeats across its two metric rows. -2. **Other = one combined column** — all present non-Scottish/unmapped codes summed into a single `Other` - column; `NotKnown` is the separate residual (not-in-demography + NULL region). -3. **Percentages = bottom row only** — a single `% of final cohort` row (each board's share of the root - final cohort), after a blank separator. - -Final column order: `Order, Type, Name, Container, SetOperation, Metric, Total, , Other, NotKnown`. - -## 6. Scope / effort - -- Change is isolated to `CohortBuildHealthBoardBreakdownReport` (new wide `BuildWide`/`ToCsv`) + the - command's drift/reconcile note + the DB test assertions (now read columns instead of board rows). -- The final-list report (`HealthBoardBreakdownReport`) is left as-is unless you also want it widened. -- Est. ~0.5–1 day incl. updated docker test, then refresh the build-plugin `.rdmp` + OneDrive. diff --git a/RdmpCohortBuildHealthBoardBreakdown/docs/TECHNICAL-BACKGROUND.html b/RdmpCohortBuildHealthBoardBreakdown/docs/TECHNICAL-BACKGROUND.html deleted file mode 100644 index 3313ace436..0000000000 --- a/RdmpCohortBuildHealthBoardBreakdown/docs/TECHNICAL-BACKGROUND.html +++ /dev/null @@ -1,230 +0,0 @@ - - - - - -Cohort Health-Board Breakdown — Technical Background - - - - -

Cohort Health-Board Breakdown

-

Technical background — how the two RDMP plugins work, what runs at each step, and the -actual SQL they generate. Written to be read top-to-bottom; no prior RDMP knowledge assumed.

- -

1. What problem this solves

-

An RDMP cohort is a list of patients defined by a tree of inclusion/exclusion rules -(e.g. “diabetics, except those who died, intersect those on drug X”). RDMP can already tell you -how many patients are in the cohort at each step. What it cannot do is tell you how those -numbers split by Scottish health board. These two plugins add exactly that:

-
    -
  • Final-list breakdown — for a finished cohort, how many patients fall in each health board.
  • -
  • Build-tree breakdown — reproduces RDMP’s per-step counts (and the running totals as each - filter is applied), split out per board, next to the national total.
  • -
- -

2. The intended workflow

-

-1Build the national cohort in RDMP (this also fills RDMP’s cache).
-2Check the split with the build-tree plugin — see how each filter trims each board.
-3Refine the filters and re-check (steps 2–3 loop).
-4Freeze the cohort, then run the final-list plugin for the committed per-board headcount. -

- -

3. Two key ideas that make it work

- -

Idea A — splitting by board is just a filter, so it “distributes”

-

Restricting the whole cohort to one board is the same as taking the result and keeping only that -board’s patients. Mathematically, that filter passes straight through the cohort’s set operations:

-
(A UNION B)     restricted to a board  =  (A restricted) UNION (B restricted)
-(A INTERSECT B) restricted to a board  =  (A restricted) INTERSECT (B restricted)
-(A EXCEPT B)    restricted to a board  =  (A restricted) EXCEPT (B restricted)
-
Practically — we never rebuild the cohort once per board (15×). We compute the -national cohort once, then slice the result by board. Same answer, a fraction of the work.
- -

Idea B — RDMP already caches the building blocks

-

When RDMP builds a cohort it saves each individual rule’s patient list into a query cache — a -real, queryable SQL table. It does not cache the combined or running totals (it recomputes those in -memory). So the cache gives us the raw ingredients; we recombine them ourselves.

-
Practically — after the one national build, every per-board number comes from -those cached tables. The original source databases are not touched again.
- -

4. Step by step — what runs, and the actual SQL

- -

Step 1 — Build the cohort once (RDMP’s engine)

-

We call RDMP’s own build engine. This runs the cohort, fills the cache, and hands back the national -counts at every node.

-
// RDMP classes — we don't reimplement any of this
-var compiler = new CohortCompiler(activator, cic) { IncludeCumulativeTotals = true };
-var runner   = new CohortCompilerRunner(compiler, timeout) { RunSubcontainers = true };
-runner.Run(token);   // executes the cohort, populates the cache, computes baseline counts
-

Each rule’s patient list is now a table in the cache, named by RDMP like:

-
[QueryCache]..[IndexedExtractionIdentifierList_AggregateConfiguration1234]
-   chi
-   ----------
-   1010101010
-   2020202020
-   ...            (one column: the patient identifier / CHI)
- -

Step 2 — Walk the cohort definition (RDMP’s tree model)

-

We read the cohort’s structure using RDMP’s own tree, so our breakdown matches its exact shape and -order (UNION / INTERSECT / EXCEPT, child order, disabled rules skipped):

-
foreach (var child in container.GetOrderedContents())   // RDMP gives the ordered tree
-{
-    // child is either a rule (AggregateConfiguration) or a sub-container
-    // container.Operation is UNION / INTERSECT / EXCEPT
-}
- -

Step 3 — Rebuild each count point from the cache (our SQL)

-

For a single rule, the “patient list” is just its cache table:

-
SELECT chi AS id FROM [QueryCache]..[IndexedExtractionIdentifierList_AggregateConfiguration1234]
-

For a container, we stitch its children together with its operation. Example — the inclusion container -is Registry INTERSECT Demography:

-
(SELECT chi AS id FROM [QueryCache]..[...Registry])
-INTERSECT
-(SELECT chi AS id FROM [QueryCache]..[...Demography])
-

For a cumulative (running-total) point — e.g. “the cohort after applying Excl1 and Excl2” — we -combine the rules up to that point, in order:

-
(
-   (SELECT chi AS id FROM [...Registry]) INTERSECT (SELECT chi AS id FROM [...Demography])
-)
-EXCEPT (SELECT chi AS id FROM [...Excl1])
-EXCEPT (SELECT chi AS id FROM [...Excl2])
-
Hand-written — RDMP caches only the individual rules, not these combinations, -so this recombination SQL is ours. It’s a direct translation of the tree (one UNION/INTERSECT/EXCEPT -per branch). In code it’s a few lines: -
string Compose(container, children) =>
-    string.Join("\n" + container.Operation + "\n",
-                children.Select(ch => "(" + IdSql(ch) + ")"));
-
- -

Step 4 — Split that list by health board (our SQL)

-

We wrap any of the lists above and join it to the demography table to attach a region, then group:

-
SELECT d.[Region] AS Region, COUNT(DISTINCT i.id) AS n
-FROM ( <the list from step 3> ) i
-INNER JOIN [SHARE]..[Demography] d ON d.[chi] = i.id
-GROUP BY d.[Region]
-

One query returns every board at once:

-
Region   n
-------   --
-T        32
-G        20
-F        13
-
Practically — one small query per count point gives all boards. There are a few -dozen count points in a typical cohort, so the whole breakdown is a few dozen cheap cache queries — not a -full rebuild, and not one-query-per-board.
- -

Step 5 — Translate region codes to boards (hard-coded lookup)

-

The demography table stores a one-letter cipher (T, G…). -RDMP has no concept of Scottish health boards, so we keep a fixed lookup:

-
["T"] = new("T", 3,  "Tayside",                 "East"),
-["G"] = new("G", 16, "Greater Glasgow & Clyde", "West"),
-["F"] = new("F", 4,  "Fife",                    "East"),
-// ... 15 Scottish boards, each: cipher -> (name, HB code, node)
-
-HealthBoard Resolve(code) => known(code) ? thatBoard : /* fall-through */ Unknown;
-
Hard-coded — this lookup table is the one piece RDMP can’t supply; it came from -the SHARE demography region codes. It lives in a single small file so it’s easy to update if codes change.
- -

Step 6 — Sort into Boards / Other / NotKnown (our logic)

-

Every region code the query returned is sorted into one of three buckets:

-
foreach (var (code, n) in resultsFromStep4)
-    if (Resolve(code) is a real Scottish board)  boards[code] = n;   // a column
-    else                                         other      += n;   // non-Scottish / unmapped
-
-notKnown = nationalTotal - sum(boards) - other;   // not in demography, or null region
- - - - - -
BucketMeaning
Board columnsthe 15 recognised Scottish boards
Othera region code that is present but isn’t a Scottish board (e.g. an - out-of-area / non-Scottish code) — previously these were invisible
NotKnownpatients with no demography record at all, or a blank region
- -

5. The output (build-tree plugin)

-

A wide CSV: one row per rule/container (name written once), a Total column (RDMP’s national -number), a column per board, then Other and NotKnown, and a percentage row at the bottom. Final -= that rule’s own count; Cumulative = the running cohort size after that step. The -column header is repeated just above the % row so each percentage lines up under its board.

-
Name        Metric      Total  Fife  Tayside  Glasgow  Other  NotKnown
-Root        Final          58    12       29       17      0         0
-Inclusion   Final         100    20       50       30      0         0
-Registry    Final         120    20       50       30      0        20
-Excl1       Cumulative     80    16       40       24      0         0
-Excl2       Cumulative     65    13       32       20      0         0
-Excl4       Cumulative     58    12       29       17      0         0
-
-Name        Metric      Total  Fife  Tayside  Glasgow  Other  NotKnown   <- header repeated
-% of final cohort         100.0  20.7     50.0     29.3    0.0       0.0
-
Clean names — RDMP internally prefixes set names with cic_<id>_, -and cloning a cohort across configurations stacks them (e.g. -cic_18286_cic_18284_cic_17950_People in SHARE…). The report strips those prefixes for -display, so you see the real name (People in SHARE Current For Contact [Recruitment], -Excl Grp 1 and 2).
- -

6. The built-in correctness check

-

On every row, the board columns plus Other plus NotKnown must add back up to the Total — and the Total -is RDMP’s own national figure. If they ever disagreed, it would flag a data/key problem.

-
sum(board columns) + Other + NotKnown  ==  Total   // checked on every row
-

We also validate the whole thing against a synthetic dataset with hand-calculated numbers (a -cohort of 100 split across three boards) — the plugin reproduces every figure exactly.

- -

7. Summary — reused vs. built

- - - - - - -
RDMP internals we reusedWhat we wrote / hard-coded
-
    -
  • CohortCompiler / CohortCompilerRunner — build + counts
  • -
  • CachedAggregateConfigurationResultsManager — the query cache
  • -
  • CohortAggregateContainer tree (order, operation)
  • -
  • ExtractableCohort / CohortQueryBuilder — cohort identifier lists
  • -
  • DataAccessPortal + FAnsi — run SQL safely
  • -
  • Command + plugin framework (CLI verb, GUI right-click, .rdmp packaging)
  • -
-
-
    -
  • The Scottish health-board lookup (cipher → board/node)
  • -
  • The recombination SQL (UNION/INTERSECT/EXCEPT over cache tables)
  • -
  • The GROUP BY region join
  • -
  • Other / NotKnown bucketing + the wide report + percentages
  • -
  • Defaults: catalogue SHARE_Demography, column Region, join on CHI, same-server
  • -
-
- -

-One honest caveat: the board mapping is hard-coded, so if region codes or board groupings change, that one -lookup file needs updating. Everything else is driven by RDMP’s own model of the cohort.

- - - From 3e99c2007dbe1fe56839739921c27c6db6f17173 Mon Sep 17 00:00:00 2001 From: mtinti Date: Wed, 1 Jul 2026 15:43:59 +0100 Subject: [PATCH 03/16] Use plain punctuation in the package README (drop em-dashes) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0169JCnaL3fhhZjseDx2XXT2 --- RdmpCohortBuildHealthBoardBreakdown/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RdmpCohortBuildHealthBoardBreakdown/README.md b/RdmpCohortBuildHealthBoardBreakdown/README.md index 4a15197975..eecf4258d8 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/README.md +++ b/RdmpCohortBuildHealthBoardBreakdown/README.md @@ -18,7 +18,7 @@ This folder is a self-contained package: the ready-to-install plugin, install/us It builds the national cohort once (which populates RDMP's query cache), then recomposes every count point purely from the cached per-set identifier tables and splits each by `SHARE_Demography.Region` -with one `GROUP BY` per node — all boards at once. No per-board rebuild, and no hits on the source +with one `GROUP BY` per node, all boards at once. No per-board rebuild, and no hits on the source catalogues after the single build (so it is cross-server safe). Requires a query-caching server, and the demography catalogue on the same server as the cache. From e2de3b2c3751779ffd8b18576e1019d3b92ee073 Mon Sep 17 00:00:00 2001 From: mtinti Date: Thu, 9 Jul 2026 07:01:51 +0100 Subject: [PATCH 04/16] Add "% of demography" sanity-check row to the plugin package Refresh the built .rdmp and the src snapshot for the new "% of demography" reference row (each board's share of the whole demography population, shown under "% of final cohort" for a cohort-vs-population comparison). README updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0169JCnaL3fhhZjseDx2XXT2 --- RdmpCohortBuildHealthBoardBreakdown/README.md | 4 +- .../RdmpCohortBuildHealthBoardBreakdown.rdmp | Bin 15537 -> 15865 bytes .../CohortBuildHealthBoardBreakdownReport.cs | 42 ++++++++++++------ ...ndExportCohortBuildHealthBoardBreakdown.cs | 37 ++++++++++++++- 4 files changed, 67 insertions(+), 16 deletions(-) diff --git a/RdmpCohortBuildHealthBoardBreakdown/README.md b/RdmpCohortBuildHealthBoardBreakdown/README.md index eecf4258d8..9844c9f25f 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/README.md +++ b/RdmpCohortBuildHealthBoardBreakdown/README.md @@ -27,7 +27,9 @@ the demography catalogue on the same server as the cache. Wide CSV: one row per count point (name once), a `Metric` column (Final + Cumulative), a `Total` column (RDMP's national number), one column per Scottish board, then `Other` (present non-Scottish / unmapped region codes) and `NotKnown` (not in demography / null region). The column header is repeated -above a `% of final cohort` row. Boards + Other + NotKnown reconcile to Total on every row. +above a `% of final cohort` row and a `% of demography` row (each board's share of the whole demography +population, for a cohort-vs-population sanity check). Boards + Other + NotKnown reconcile to Total on +every row. ## Validation diff --git a/RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp b/RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp index 8226c6908e7058ba93d7f2ab66cef33aa451cbcf..f71cc5210101f22bf85a5e6e49b301fe055faa68 100644 GIT binary patch delta 14940 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 U2+02rAy$9{%241xyC>NH0`3e1y#N3J delta 14634 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 MKc*3j?|*pz7d$VGwEzGB diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs b/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs index e3c8450aa3..6fee822b43 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs +++ b/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs @@ -27,6 +27,9 @@ public static class CohortBuildHealthBoardBreakdownReport public const string NotKnownColumn = "NotKnown"; public const string PercentMetric = "% of final cohort"; + /// Label for the reference row: each board's share of the whole demography population. + public const string DemographyPercentMetric = "% of demography"; + /// One count point of the build tree with its per-region counts (known boards only). public sealed class NodeBreakdown { @@ -92,9 +95,10 @@ public static Buckets Split(int total, IReadOnlyDictionary byRegion } /// The ordered mapped boards that appear anywhere (column order: node then name). - private static List BoardColumns(IEnumerable nodes) => + private static List BoardColumns(IEnumerable nodes, Buckets demographyReference) => nodes .SelectMany(n => n.FinalByRegion.Keys.Concat(n.CumulativeByRegion?.Keys ?? Enumerable.Empty())) + .Concat(demographyReference?.Boards.Keys ?? Enumerable.Empty()) .Select(HealthBoardLookup.Resolve) .Where(b => b.Node != HealthBoardLookup.UnknownNode) .GroupBy(b => b.Region, System.StringComparer.OrdinalIgnoreCase) @@ -103,11 +107,15 @@ private static List BoardColumns(IEnumerable nodes) .ThenBy(b => b.Name, System.StringComparer.OrdinalIgnoreCase) .ToList(); - /// Builds the wide CSV (data rows per node+metric, then a % of final cohort row). - public static string ToCsv(IReadOnlyList nodes) + /// + /// Builds the wide CSV (data rows per node+metric, then a % of final cohort row and, when + /// is supplied, a % of demography row underneath it + /// giving each board's share of the whole demography population, as a sanity check). + /// + public static string ToCsv(IReadOnlyList nodes, Buckets demographyReference = null) { var ordered = nodes.OrderBy(n => n.Seq).ToList(); - var boards = BoardColumns(ordered); + var boards = BoardColumns(ordered, demographyReference); var header = new List { "Order", "Type", "Name", "Container", "SetOperation", "Metric", "Total" }; header.AddRange(boards.Select(b => b.Name)); @@ -125,24 +133,30 @@ public static string ToCsv(IReadOnlyList nodes) Split(n.CumulativeUnfiltered.Value, n.CumulativeByRegion)); } - // bottom: % of final cohort (root node's Final), after a blank separator + // bottom: % of final cohort (root node's Final), then % of demography, after a blank separator var root = ordered.FirstOrDefault(n => string.IsNullOrEmpty(n.Container)) ?? ordered.FirstOrDefault(); if (root != null && root.FinalUnfiltered > 0) { sb.AppendLine(); sb.AppendLine(string.Join(",", header.Select(Escape))); // repeat header so % aligns to each board - var b = Split(root.FinalUnfiltered, root.FinalByRegion); - double Pct(int v) => v * 100.0 / b.Total; - var cells = new List { "", "", PercentMetric, "", "", PercentMetric, Fmt(100.0) }; - cells.AddRange(boards.Select(bd => Fmt(Pct(b.Boards.TryGetValue(bd.Region, out var v) ? v : 0)))); - cells.Add(Fmt(Pct(b.Other))); - cells.Add(Fmt(Pct(b.NotKnown))); - sb.AppendLine(string.Join(",", cells.Select(Escape))); + AppendPercentRow(sb, PercentMetric, boards, Split(root.FinalUnfiltered, root.FinalByRegion)); + if (demographyReference != null) + AppendPercentRow(sb, DemographyPercentMetric, boards, demographyReference); } return sb.ToString(); } + private static void AppendPercentRow(StringBuilder sb, string label, List boards, Buckets b) + { + double Pct(int v) => b.Total == 0 ? 0 : v * 100.0 / b.Total; + var cells = new List { "", "", label, "", "", label, Fmt(b.Total == 0 ? 0 : 100.0) }; + cells.AddRange(boards.Select(bd => Fmt(Pct(b.Boards.TryGetValue(bd.Region, out var v) ? v : 0)))); + cells.Add(Fmt(Pct(b.Other))); + cells.Add(Fmt(Pct(b.NotKnown))); + sb.AppendLine(string.Join(",", cells.Select(Escape))); + } + private static void AppendCountRow(StringBuilder sb, NodeBreakdown n, List boards, string metric, Buckets b) { @@ -159,8 +173,8 @@ private static void AppendCountRow(StringBuilder sb, NodeBreakdown n, List nodes) => - File.WriteAllText(path, ToCsv(nodes)); + public static void WriteCsv(string path, IReadOnlyList nodes, Buckets demographyReference = null) => + File.WriteAllText(path, ToCsv(nodes, demographyReference)); private static string Fmt(double d) => d.ToString("0.0", CultureInfo.InvariantCulture); diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs b/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs index e725d4eaf4..a99e86e46c 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs +++ b/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs @@ -176,7 +176,10 @@ public override void Execute() var seq = 0; Walk(_cic.RootCohortAggregateContainer, null, 0, nodes, ref seq); - CohortBuildHealthBoardBreakdownReport.WriteCsv(_toFile.FullName, nodes); + // reference: each board's share of the WHOLE demography population (a sanity check row) + var demographyReference = ComputeDemographyReference(); + + CohortBuildHealthBoardBreakdownReport.WriteCsv(_toFile.FullName, nodes, demographyReference); // reconciliation note var drift = nodes.Count(n => @@ -312,6 +315,38 @@ private IReadOnlyDictionary RunRegionCounts(string idListSql) return result; } + /// + /// The whole demography population split by region (the reference/background distribution). Total + /// includes NULL-region rows so + /// captures them. + /// + private CohortBuildHealthBoardBreakdownReport.Buckets ComputeDemographyReference() + { + var sql = + $"SELECT d.[{_regionName}] AS Region, COUNT(DISTINCT d.[{_demogId}]) AS n\n" + + $"FROM {_demogTable} d\n" + + $"GROUP BY d.[{_regionName}]"; + + var byRegion = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + var total = 0; + using (var con = _cacheDb.Server.GetConnection()) + { + con.Open(); + using var cmd = _cacheDb.Server.GetCommand(sql, con); + cmd.CommandTimeout = _timeout; + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var n = System.Convert.ToInt32(r["n"]); + total += n; // includes the NULL-region group in the denominator + if (r["Region"] != System.DBNull.Value) + byRegion[r["Region"].ToString()] = n; + } + } + + return CohortBuildHealthBoardBreakdownReport.Split(total, byRegion); + } + // RDMP prefixes cohort set names with "cic__" (EnsureNamingConvention); cloning a cohort across // CICs stacks them (e.g. cic_18286_cic_18284_cic_17950_People in SHARE...). Strip them for display. private static readonly Regex CicPrefix = new(@"^(cic_\d+_)+", RegexOptions.Compiled); From e9c59af68bb4a952a449f220a4af9bbad19c8522 Mon Sep 17 00:00:00 2001 From: mtinti Date: Fri, 10 Jul 2026 09:44:42 +0100 Subject: [PATCH 05/16] Address review: data-driven lookup + RDMP-object args (plugin package) Refresh the package for the reworked plugin: region -> name/node mapping now comes from a user-supplied lookup table (RegionLookup) instead of a hardcoded list; command takes ICatalogue / ColumnInfo / TableInfo (mappable from the CLI, no string defaults, prompted in the GUI); SQL quoting via the query-syntax helper. Adds RegionLookup + CohortBuildBreakdownModels sources, drops HealthBoardLookup, rebuilt .rdmp, README/INSTALL updated for the new inputs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0169JCnaL3fhhZjseDx2XXT2 --- .../INSTALL.md | 54 ++-- RdmpCohortBuildHealthBoardBreakdown/README.md | 32 +-- .../RdmpCohortBuildHealthBoardBreakdown.rdmp | Bin 15865 -> 15955 bytes .../src/CohortBuildBreakdownModels.cs | 88 +++++++ .../CohortBuildHealthBoardBreakdownReport.cs | 141 ++++------ ...ndExportCohortBuildHealthBoardBreakdown.cs | 247 ++++++++---------- .../src/HealthBoardLookup.cs | 61 ----- .../src/RegionLookup.cs | 77 ++++++ 8 files changed, 380 insertions(+), 320 deletions(-) create mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildBreakdownModels.cs delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/HealthBoardLookup.cs create mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/RegionLookup.cs diff --git a/RdmpCohortBuildHealthBoardBreakdown/INSTALL.md b/RdmpCohortBuildHealthBoardBreakdown/INSTALL.md index 780967f951..7c8984f2da 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/INSTALL.md +++ b/RdmpCohortBuildHealthBoardBreakdown/INSTALL.md @@ -1,56 +1,62 @@ # RdmpCohortBuildHealthBoardBreakdown plugin (RDMP 9.2.3) Reproduces the Cohort Builder's per-set / per-container count tree (the FinalCount and cumulative -running totals shown as UNION/INTERSECT/EXCEPT are applied) **split by Scottish health board**, plus -an unfiltered baseline. Saved as a long-format CSV. +running totals shown as UNION/INTERSECT/EXCEPT are applied) **split by region** (e.g. Scottish health +board), plus an unfiltered national total. Saved as a wide CSV. Built against the **released RDMP 9.2.3**. Do not use on a different major.minor RDMP. ## How it works (cache-only, cross-server safe) It builds the cohort **once** (populating the query cache), then recomposes every count point from the -cached per-set identifier tables and splits each by `SHARE_Demography.Region` with one GROUP BY per -node. It never re-runs the source catalogues per board, and never touches the source servers after the -single build — only the query-cache server (which is why the demography catalogue must be on the same -server as the query cache). +cached per-set identifier tables and splits each by the region column with one GROUP BY per node. It +never re-runs the source catalogues per region, and never touches the source servers after the single +build, only the query-cache server (which is why the demography catalogue must be on the same server as +the query cache). The region-to-name/node mapping is read from a user-supplied lookup table, so nothing +is hard-coded. ## Requirements - The cohort identification configuration must have a **query caching server** configured (the breakdown works only on cached results; it refuses otherwise). -- `SHARE_Demography` (with a `Region` health-board cipher column and a CHI IsExtractionIdentifier - column) must be on the **same SQL server as the query cache** (the command checks and refuses if not). +- The **demography catalogue** (with a CHI IsExtractionIdentifier column and the region column) must be + on the **same SQL server as the query cache** (the command checks and refuses if not). +- A **lookup table** mapping each region code to a name and node, with columns: + `Region` (the code as it appears in the demography data), `HB_Name` (display name), `SafeHaven_Region` + (grouping node, may be NULL). An `HB_Code` column may be present but is ignored. One row per code. ## Install -**GUI:** RDMP desktop → Plugins node → *Add Plugin* (or drag `RdmpCohortBuildHealthBoardBreakdown.rdmp` -onto it) → restart RDMP. **Or** drop the `.rdmp` next to `rdmp.exe` / +**GUI:** RDMP desktop, Plugins node, *Add Plugin* (or drag `RdmpCohortBuildHealthBoardBreakdown.rdmp` +onto it), restart RDMP. **Or** drop the `.rdmp` next to `rdmp.exe` / `ResearchDataManagementPlatform.exe`. Confirm (CLI): `rdmp.exe cmd ListSupportedCommands` lists `ExportCohortBuildHealthBoardBreakdown`. ## Use -**GUI:** right-click a Cohort Identification Configuration → *Export ... Build Health Board Breakdown* -→ choose a CSV path. +**GUI:** right-click a Cohort Identification Configuration, choose the export command. It prompts for the +demography catalogue, the region column, and the lookup table. -**CLI:** +**CLI:** the inputs are RDMP objects, mapped by id: ``` -rdmp.exe cmd ExportCohortBuildHealthBoardBreakdown CohortIdentificationConfiguration: out.csv "SHARE_Demography" "Region" +rdmp.exe cmd ExportCohortBuildHealthBoardBreakdown \ + CohortIdentificationConfiguration: Catalogue: ColumnInfo: TableInfo: out.csv ``` -Args after the CIC are optional (defaults: `-build-healthboard.csv`, `SHARE_Demography`, `Region`). +`out.csv` and the timeout are optional; the object arguments are required (no hard-coded defaults). -## Output (long format, board-grouped) +## Output (wide format) -Columns: `Board, Node, Order, Type, Name, Container, SetOperation, FinalCount, CumulativeCount`. The -`Unfiltered` tree first (RDMP's own numbers), then each health board's full tree (boards partition the -cohort, 1 patient ↔ 1 board), then an `Unknown` board (patients with no / unmapped region). `FinalCount` -is the node's own count; `CumulativeCount` is the running total within the parent container (blank for -the first child, as in the UI). Boards (+ Unknown) reconcile to the unfiltered total at every node. +One row per count point (name written once), a `Metric` column (Final and Cumulative), a `Total` column +(RDMP's national number), one column per region recognised by the lookup, then `Other` (present codes the +lookup does not recognise) and `NotKnown` (not in demography, or NULL region). The column header is +repeated above two percentage rows: `% of final cohort` and `% of demography` (each region's share of the +whole demography population, a cohort-vs-population sanity check). Regions + Other + NotKnown reconcile to +Total on every row. ## Validation Verified end-to-end on a deterministic synthetic fixture (top EXCEPT over an inclusion INTERSECT minus -four exclusion sets, cohort partitioned across 3 boards): every national and per-board FinalCount and -cumulative was asserted cell-by-cell, the unfiltered column equals RDMP's own CohortCompiler counts, and -the boards sum to national at every node. +four exclusion sets, cohort partitioned across regions, driven by a synthetic lookup table): every +national and per-region FinalCount and cumulative was asserted cell-by-cell, the unfiltered column equals +RDMP's own CohortCompiler counts, and the regions sum to national at every node. diff --git a/RdmpCohortBuildHealthBoardBreakdown/README.md b/RdmpCohortBuildHealthBoardBreakdown/README.md index 9844c9f25f..16ebcfcd07 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/README.md +++ b/RdmpCohortBuildHealthBoardBreakdown/README.md @@ -1,8 +1,9 @@ # RdmpCohortBuildHealthBoardBreakdown (RDMP 9.2.3 plugin) Reproduces the Cohort Builder's per-set / per-container count tree (the `FinalCount` and cumulative -running totals shown as UNION/INTERSECT/EXCEPT are applied) **split by Scottish health board**, plus an -unfiltered national total, and writes it to a wide CSV. +running totals shown as UNION/INTERSECT/EXCEPT are applied) **split by region** (e.g. Scottish health +board), plus an unfiltered national total, and writes it to a wide CSV. The region-to-name/node mapping +is read from a user-supplied lookup table (nothing is hard-coded). This folder is a self-contained package: the ready-to-install plugin, install/usage notes, and the source. @@ -11,32 +12,33 @@ This folder is a self-contained package: the ready-to-install plugin, install/us | Path | What it is | |---|---| | `RdmpCohortBuildHealthBoardBreakdown.rdmp` | the built plugin (drop into RDMP / add via the Plugins node) | -| `INSTALL.md` | install + usage (GUI right-click and CLI) | -| `src/` | plugin source (command, report, health-board lookup, UI hook, csproj, nuspec) | +| `INSTALL.md` | install + usage (GUI right-click and CLI), including the lookup-table schema | +| `src/` | plugin source (command, report, region lookup, models, UI hook, csproj, nuspec) | ## How it works (in one paragraph) It builds the national cohort once (which populates RDMP's query cache), then recomposes every count -point purely from the cached per-set identifier tables and splits each by `SHARE_Demography.Region` -with one `GROUP BY` per node, all boards at once. No per-board rebuild, and no hits on the source -catalogues after the single build (so it is cross-server safe). Requires a query-caching server, and -the demography catalogue on the same server as the cache. +point purely from the cached per-set identifier tables and splits each by the region column with one +`GROUP BY` per node, all regions at once. No per-region rebuild, and no hits on the source catalogues +after the single build (so it is cross-server safe). Inputs are RDMP objects (an `ICatalogue` for +demography, a `ColumnInfo` for the region column, a `TableInfo` for the lookup); requires a query-caching +server, with the demography catalogue on the same server as the cache. ## Output Wide CSV: one row per count point (name once), a `Metric` column (Final + Cumulative), a `Total` -column (RDMP's national number), one column per Scottish board, then `Other` (present non-Scottish / -unmapped region codes) and `NotKnown` (not in demography / null region). The column header is repeated -above a `% of final cohort` row and a `% of demography` row (each board's share of the whole demography -population, for a cohort-vs-population sanity check). Boards + Other + NotKnown reconcile to Total on -every row. +column (RDMP's national number), one column per region recognised by the lookup, then `Other` (present +codes the lookup does not recognise) and `NotKnown` (not in demography / null region). The column header +is repeated above a `% of final cohort` row and a `% of demography` row (each region's share of the whole +demography population, for a cohort-vs-population sanity check). Regions + Other + NotKnown reconcile to +Total on every row. ## Validation Verified end-to-end against a deterministic synthetic fixture (top EXCEPT over an inclusion INTERSECT -minus four exclusion sets, the cohort partitioned across 3 boards): every national and per-board +minus four exclusion sets, driven by a synthetic lookup table): every national and per-region `FinalCount` / cumulative is asserted cell-by-cell, the unfiltered column equals RDMP's own -`CohortCompiler` counts, and the boards sum to national at every node. +`CohortCompiler` counts, and the regions sum to national at every node. ## Build from source (optional) diff --git a/RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp b/RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp index f71cc5210101f22bf85a5e6e49b301fe055faa68..b603a3a903ef901af7d15ae61ab78fed70614b0c 100644 GIT binary patch delta 15031 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 delta 14938 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 XH{H1@sQf>t8HGro3. + +using System.Collections.Generic; + +namespace Rdmp.Core.CohortCreation; + +/// +/// One count point of a cohort build tree (a cohort set or a container) with the per-region counts that +/// were computed for it. Used by . +/// +public sealed class CohortBuildBreakdownNode +{ + /// Tree walk order (stable, used to order rows). + public int Seq { get; } + + /// "Cohort Set" or "Container". + public string Type { get; } + + public string Name { get; } + + /// Name of the parent container (empty for the root). + public string Container { get; } + + /// UNION / INTERSECT / EXCEPT for containers; empty for sets. + public string SetOperation { get; } + + public int DisplayOrder { get; } + + /// RDMP's own count for this node (the unfiltered/national total). + public int FinalUnfiltered { get; } + + /// RDMP's own cumulative within the parent container; null if not applicable. + public int? CumulativeUnfiltered { get; } + + /// Region code -> final count (every present code; the GROUP BY Region result). + public IReadOnlyDictionary FinalByRegion { get; } + + /// Region code -> cumulative count; null when this node has no cumulative. + public IReadOnlyDictionary CumulativeByRegion { get; } + + public CohortBuildBreakdownNode(int seq, string type, string name, string container, string setOperation, + int displayOrder, int finalUnfiltered, int? cumulativeUnfiltered, + IReadOnlyDictionary finalByRegion, IReadOnlyDictionary cumulativeByRegion) + { + Seq = seq; + Type = type ?? ""; + Name = name ?? ""; + Container = container ?? ""; + SetOperation = setOperation ?? ""; + DisplayOrder = displayOrder; + FinalUnfiltered = finalUnfiltered; + CumulativeUnfiltered = cumulativeUnfiltered; + FinalByRegion = finalByRegion ?? new Dictionary(); + CumulativeByRegion = cumulativeByRegion; + } +} + +/// +/// The Total / per-region / Other / NotKnown split of one node+metric, relative to a +/// . holds the counts for codes present in the lookup; +/// sums present codes absent from the lookup; is the residual +/// (not in demography, or NULL region). +/// +public sealed class CohortBuildBreakdownBuckets +{ + public int Total { get; } + + /// Region code -> count, for codes recognised by the lookup. + public IReadOnlyDictionary Regions { get; } + + /// Sum of present region codes that the lookup does not recognise. + public int Other { get; } + + /// Total - recognised - Other = not in demography + NULL region. + public int NotKnown { get; } + + public CohortBuildBreakdownBuckets(int total, IReadOnlyDictionary regions, int other, int notKnown) + { + Total = total; + Regions = regions ?? new Dictionary(); + Other = other; + NotKnown = notKnown; + } +} diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs b/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs index 6fee822b43..e56f509db6 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs +++ b/RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs @@ -14,12 +14,13 @@ namespace Rdmp.Core.CohortCreation; /// /// Projects a cohort build's count tree (the per-set / per-container FinalCount and cumulative -/// running totals shown in the Cohort Builder) split by health board into a WIDE CSV: one row per -/// (count-point × metric), the container/set name written once, a Total column (RDMP's own -/// national count), one column per Scottish health board, an Other column (present non-Scottish / -/// unmapped region codes) and a NotKnown residual (patients not in demography / NULL region). -/// A bottom % of final cohort row gives each board's share of the final national cohort. -/// Boards + Other + NotKnown reconcile to Total on every row. +/// running totals shown in the Cohort Builder) split by region into a WIDE CSV: one row per +/// (count-point x metric), the container/set name written once, a Total column (RDMP's own +/// national count), one column per region recognised by the supplied , an +/// Other column (present codes the lookup does not recognise) and a NotKnown residual +/// (patients not in demography / NULL region). Two bottom rows give each region's share of the final +/// cohort and of the whole demography population (a sanity check). Regions + Other + NotKnown reconcile +/// to Total on every row. /// public static class CohortBuildHealthBoardBreakdownReport { @@ -27,98 +28,58 @@ public static class CohortBuildHealthBoardBreakdownReport public const string NotKnownColumn = "NotKnown"; public const string PercentMetric = "% of final cohort"; - /// Label for the reference row: each board's share of the whole demography population. + /// Label for the reference row: each region's share of the whole demography population. public const string DemographyPercentMetric = "% of demography"; - /// One count point of the build tree with its per-region counts (known boards only). - public sealed class NodeBreakdown - { - public int Seq { get; init; } - public string Type { get; init; } = ""; - public string Name { get; init; } = ""; - - /// Parent container name (empty for the root). - public string Container { get; init; } = ""; - - public string SetOperation { get; init; } = ""; - public int DisplayOrder { get; init; } - - /// RDMP's own count for this node (the unfiltered/national total). - public int FinalUnfiltered { get; init; } - - /// RDMP's own cumulative within the parent container; null if not applicable. - public int? CumulativeUnfiltered { get; init; } - - /// Region cipher → final count (every present code; GROUP BY Region result). - public IReadOnlyDictionary FinalByRegion { get; init; } = new Dictionary(); - - /// Region cipher → cumulative count; null when this node has no cumulative. - public IReadOnlyDictionary CumulativeByRegion { get; init; } - } - - /// The Total / per-board / Other / NotKnown counts for one node+metric. - public sealed class Buckets - { - public int Total { get; init; } - - /// Region cipher → count (mapped Scottish boards only). - public IReadOnlyDictionary Boards { get; init; } = new Dictionary(); - - /// Sum of present region codes that are NOT one of the 15 Scottish boards. - public int Other { get; init; } - - /// Total − boards − Other = not-in-demography + NULL region. - public int NotKnown { get; init; } - } + /// A resolved output column (a region recognised by the lookup). + private sealed record RegionColumn(string Code, string Name, string Node); /// - /// Splits one node's region counts into Total / mapped-boards / Other / NotKnown. - /// is the GROUP BY Region result (every present code); is RDMP's own count. + /// Splits one node's region counts into Total / recognised-regions / Other / NotKnown using + /// . is the GROUP BY Region result (every present + /// code); is RDMP's own count. /// - public static Buckets Split(int total, IReadOnlyDictionary byRegion) + public static CohortBuildBreakdownBuckets Split(int total, IReadOnlyDictionary byRegion, + RegionLookup lookup) { - var boards = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + var regions = new Dictionary(System.StringComparer.OrdinalIgnoreCase); var other = 0; foreach (var (code, n) in byRegion) - if (HealthBoardLookup.Resolve(code).Node == HealthBoardLookup.UnknownNode) - other += n; // present but not a Scottish board (non-Scottish / unmapped) + if (lookup.Contains(code)) + regions[code] = n; else - boards[code] = n; + other += n; // present but not recognised by the lookup - return new Buckets - { - Total = total, - Boards = boards, - Other = other, - NotKnown = total - boards.Values.Sum() - other - }; + return new CohortBuildBreakdownBuckets(total, regions, other, total - regions.Values.Sum() - other); } - /// The ordered mapped boards that appear anywhere (column order: node then name). - private static List BoardColumns(IEnumerable nodes, Buckets demographyReference) => + /// The recognised regions that appear anywhere, ordered by node (nulls last) then name. + private static List RegionColumns(IEnumerable nodes, + CohortBuildBreakdownBuckets demographyReference, RegionLookup lookup) => nodes .SelectMany(n => n.FinalByRegion.Keys.Concat(n.CumulativeByRegion?.Keys ?? Enumerable.Empty())) - .Concat(demographyReference?.Boards.Keys ?? Enumerable.Empty()) - .Select(HealthBoardLookup.Resolve) - .Where(b => b.Node != HealthBoardLookup.UnknownNode) - .GroupBy(b => b.Region, System.StringComparer.OrdinalIgnoreCase) - .Select(g => g.First()) - .OrderBy(b => b.Node, System.StringComparer.OrdinalIgnoreCase) - .ThenBy(b => b.Name, System.StringComparer.OrdinalIgnoreCase) + .Concat(demographyReference?.Regions.Keys ?? Enumerable.Empty()) + .Where(lookup.Contains) + .GroupBy(code => code, System.StringComparer.OrdinalIgnoreCase) + .Select(g => new RegionColumn(g.Key, lookup.NameOf(g.Key), lookup.NodeOf(g.Key))) + .OrderBy(c => string.IsNullOrEmpty(c.Node) ? 1 : 0) // nodeless regions (e.g. non-Scottish) last + .ThenBy(c => c.Node, System.StringComparer.OrdinalIgnoreCase) + .ThenBy(c => c.Name, System.StringComparer.OrdinalIgnoreCase) .ToList(); /// /// Builds the wide CSV (data rows per node+metric, then a % of final cohort row and, when - /// is supplied, a % of demography row underneath it - /// giving each board's share of the whole demography population, as a sanity check). + /// is supplied, a % of demography row underneath it as + /// a cohort-vs-population sanity check). /// - public static string ToCsv(IReadOnlyList nodes, Buckets demographyReference = null) + public static string ToCsv(IReadOnlyList nodes, RegionLookup lookup, + CohortBuildBreakdownBuckets demographyReference = null) { var ordered = nodes.OrderBy(n => n.Seq).ToList(); - var boards = BoardColumns(ordered, demographyReference); + var columns = RegionColumns(ordered, demographyReference, lookup); var header = new List { "Order", "Type", "Name", "Container", "SetOperation", "Metric", "Total" }; - header.AddRange(boards.Select(b => b.Name)); + header.AddRange(columns.Select(c => c.Name)); header.Add(OtherColumn); header.Add(NotKnownColumn); @@ -127,10 +88,10 @@ public static string ToCsv(IReadOnlyList nodes, Buckets demograph foreach (var n in ordered) { - AppendCountRow(sb, n, boards, "Final", Split(n.FinalUnfiltered, n.FinalByRegion)); + AppendCountRow(sb, n, columns, "Final", Split(n.FinalUnfiltered, n.FinalByRegion, lookup)); if (n.CumulativeUnfiltered.HasValue && n.CumulativeByRegion != null) - AppendCountRow(sb, n, boards, "Cumulative", - Split(n.CumulativeUnfiltered.Value, n.CumulativeByRegion)); + AppendCountRow(sb, n, columns, "Cumulative", + Split(n.CumulativeUnfiltered.Value, n.CumulativeByRegion, lookup)); } // bottom: % of final cohort (root node's Final), then % of demography, after a blank separator @@ -138,27 +99,28 @@ public static string ToCsv(IReadOnlyList nodes, Buckets demograph if (root != null && root.FinalUnfiltered > 0) { sb.AppendLine(); - sb.AppendLine(string.Join(",", header.Select(Escape))); // repeat header so % aligns to each board - AppendPercentRow(sb, PercentMetric, boards, Split(root.FinalUnfiltered, root.FinalByRegion)); + sb.AppendLine(string.Join(",", header.Select(Escape))); // repeat header so % aligns to each region + AppendPercentRow(sb, PercentMetric, columns, Split(root.FinalUnfiltered, root.FinalByRegion, lookup)); if (demographyReference != null) - AppendPercentRow(sb, DemographyPercentMetric, boards, demographyReference); + AppendPercentRow(sb, DemographyPercentMetric, columns, demographyReference); } return sb.ToString(); } - private static void AppendPercentRow(StringBuilder sb, string label, List boards, Buckets b) + private static void AppendPercentRow(StringBuilder sb, string label, List columns, + CohortBuildBreakdownBuckets b) { double Pct(int v) => b.Total == 0 ? 0 : v * 100.0 / b.Total; var cells = new List { "", "", label, "", "", label, Fmt(b.Total == 0 ? 0 : 100.0) }; - cells.AddRange(boards.Select(bd => Fmt(Pct(b.Boards.TryGetValue(bd.Region, out var v) ? v : 0)))); + cells.AddRange(columns.Select(c => Fmt(Pct(b.Regions.TryGetValue(c.Code, out var v) ? v : 0)))); cells.Add(Fmt(Pct(b.Other))); cells.Add(Fmt(Pct(b.NotKnown))); sb.AppendLine(string.Join(",", cells.Select(Escape))); } - private static void AppendCountRow(StringBuilder sb, NodeBreakdown n, List boards, - string metric, Buckets b) + private static void AppendCountRow(StringBuilder sb, CohortBuildBreakdownNode n, List columns, + string metric, CohortBuildBreakdownBuckets b) { var cells = new List { @@ -166,15 +128,16 @@ private static void AppendCountRow(StringBuilder sb, NodeBreakdown n, List - (b.Boards.TryGetValue(bd.Region, out var v) ? v : 0).ToString(CultureInfo.InvariantCulture))); + cells.AddRange(columns.Select(c => + (b.Regions.TryGetValue(c.Code, out var v) ? v : 0).ToString(CultureInfo.InvariantCulture))); cells.Add(b.Other.ToString(CultureInfo.InvariantCulture)); cells.Add(b.NotKnown.ToString(CultureInfo.InvariantCulture)); sb.AppendLine(string.Join(",", cells.Select(Escape))); } - public static void WriteCsv(string path, IReadOnlyList nodes, Buckets demographyReference = null) => - File.WriteAllText(path, ToCsv(nodes, demographyReference)); + public static void WriteCsv(string path, IReadOnlyList nodes, RegionLookup lookup, + CohortBuildBreakdownBuckets demographyReference = null) => + File.WriteAllText(path, ToCsv(nodes, lookup, demographyReference)); private static string Fmt(double d) => d.ToString("0.0", CultureInfo.InvariantCulture); diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs b/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs index a99e86e46c..7aa2300d28 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs +++ b/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs @@ -4,13 +4,14 @@ // RDMP is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. // You should have received a copy of the GNU General Public License along with RDMP. If not, see . +using System; using System.Collections.Generic; -using System.Data; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using FAnsi.Discovery; +using FAnsi.Discovery.QuerySyntax; using Rdmp.Core.CohortCreation; using Rdmp.Core.CohortCreation.Execution; using Rdmp.Core.Curation.Data; @@ -24,24 +25,25 @@ namespace Rdmp.Core.CommandExecution.AtomicCommands; /// /// Reproduces the Cohort Builder's per-set / per-container count tree (the FinalCount and cumulative -/// running totals shown as UNION/INTERSECT/EXCEPT are applied) split by Scottish health board, and -/// writes it to a long-format CSV. Operates purely on the query cache: it builds the cohort once to -/// populate the per-set cache tables, then recomposes every count point from those cache tables and -/// splits it by SHARE_Demography.Region with one GROUP BY per node (all boards at once). +/// running totals shown as UNION/INTERSECT/EXCEPT are applied) split by a region column, using a +/// user-supplied lookup table to name/group the regions. Operates purely on the query cache: it builds +/// the cohort once to populate the per-set cache tables, then recomposes every count point from those +/// cache tables and splits it by region with one GROUP BY per node (all regions at once). /// public class ExecuteCommandExportCohortBuildHealthBoardBreakdown : BasicCommandExecution { private readonly CohortIdentificationConfiguration _cic; - private readonly string _demographyCatalogue; - private readonly string _regionColumn; + private ICatalogue _demographyCatalogue; + private ColumnInfo _regionColumn; + private TableInfo _groupLookup; private readonly int _timeout; private FileInfo _toFile; - private ExtractionInformation _regionEi; private ExtractionInformation _idEi; - + private IQuerySyntaxHelper _syntax; private DiscoveredDatabase _cacheDb; private CachedAggregateConfigurationResultsManager _cacheManager; + private RegionLookup _lookup; private string _demogTable; private string _demogId; private string _regionName; @@ -52,18 +54,21 @@ public class ExecuteCommandExportCohortBuildHealthBoardBreakdown : BasicCommandE public ExecuteCommandExportCohortBuildHealthBoardBreakdown(IBasicActivateItems activator, [DemandsInitialization("The cohort identification configuration whose build tree to break down")] CohortIdentificationConfiguration cic, - [DemandsInitialization("CSV file to write. Defaults to -build-healthboard.csv in the current directory")] + [DemandsInitialization("Demography catalogue that provides the patient identifier and the region column")] + ICatalogue demographyCatalogue = null, + [DemandsInitialization("The region column to group the breakdown by")] + ColumnInfo regionColumn = null, + [DemandsInitialization("Lookup table mapping region code to a name and node (columns: Region, HB_Name, SafeHaven_Region)")] + TableInfo groupLookup = null, + [DemandsInitialization("CSV file to write. Defaults to -build-breakdown.csv in the current directory")] FileInfo toFile = null, - [DemandsInitialization("Demography catalogue holding the region column", DefaultValue = "SHARE_Demography")] - string demographyCatalogue = "SHARE_Demography", - [DemandsInitialization("Region (health board cipher) column on the demography catalogue", DefaultValue = "Region")] - string regionColumn = "Region", [DemandsInitialization("Per-query command timeout in seconds", DefaultValue = 5000)] int timeout = 5000) : base(activator) { _cic = cic; _demographyCatalogue = demographyCatalogue; _regionColumn = regionColumn; + _groupLookup = groupLookup; _timeout = timeout; _toFile = toFile; @@ -80,69 +85,79 @@ public ExecuteCommandExportCohortBuildHealthBoardBreakdown(IBasicActivateItems a } if (_cic.QueryCachingServer_ID == null) - { SetImpossible($"'{_cic}' has no query caching server - this breakdown works only on cached results"); - return; - } - ResolveDemography(activator); + // the demography catalogue / region column / lookup table are resolved (and prompted for, in the + // GUI) in Execute, so the command can be added to a right-click menu with only the cohort selected. } - private void ResolveDemography(IBasicActivateItems activator) + /// Resolves the RDMP-object inputs, prompting the user for any not supplied. Returns false on cancel. + private bool ResolveInputs() { - var demography = activator.RepositoryLocator.CatalogueRepository - .GetAllObjects() - .FirstOrDefault(c => string.Equals(c.Name, _demographyCatalogue, System.StringComparison.OrdinalIgnoreCase)); - - if (demography == null) - { - SetImpossible($"Could not find a catalogue called '{_demographyCatalogue}'"); - return; - } + _demographyCatalogue ??= SelectOne("Demography catalogue (provides the patient identifier + region column)", + BasicActivator.RepositoryLocator.CatalogueRepository.GetAllObjects()); + if (_demographyCatalogue == null) + return Fail("No demography catalogue was supplied"); - var eis = demography.GetAllExtractionInformation(ExtractionCategory.Any); - _regionEi = eis.FirstOrDefault(e => - string.Equals(e.GetRuntimeName(), _regionColumn, System.StringComparison.OrdinalIgnoreCase)); - _idEi = eis.FirstOrDefault(e => e.IsExtractionIdentifier); + _idEi = _demographyCatalogue.GetAllExtractionInformation(ExtractionCategory.Any) + .FirstOrDefault(e => e.IsExtractionIdentifier); + if (_idEi == null) + return Fail($"'{_demographyCatalogue}' has no IsExtractionIdentifier column to join the cohort on"); - if (_regionEi == null) - { - SetImpossible($"'{_demographyCatalogue}' has no column called '{_regionColumn}'"); - return; - } + _regionColumn ??= SelectOne("Region column to group by", + _demographyCatalogue.GetAllExtractionInformation(ExtractionCategory.Any) + .Select(e => e.ColumnInfo).Where(c => c != null).Distinct().ToArray()); + if (_regionColumn == null) + return Fail("No region column was supplied"); - if (_idEi == null) - { - SetImpossible($"'{_demographyCatalogue}' has no IsExtractionIdentifier column to join the cohort on"); - return; - } + _groupLookup ??= SelectOne("Region lookup table (Region, HB_Name, SafeHaven_Region)", + BasicActivator.RepositoryLocator.CatalogueRepository.GetAllObjects()); + if (_groupLookup == null) + return Fail("No region lookup table was supplied"); // co-location: the recompose + GROUP BY join runs on the cache server, so demography must be there var cacheServer = _cic.QueryCachingServer.Server; - var demogServer = _idEi.ColumnInfo.TableInfo.Server; + var demogServer = _regionColumn.TableInfo.Server; if (!string.IsNullOrWhiteSpace(cacheServer) && !string.IsNullOrWhiteSpace(demogServer) - && !string.Equals(cacheServer.Trim(), demogServer.Trim(), System.StringComparison.OrdinalIgnoreCase)) - SetImpossible( - $"Demography catalogue is on server '{demogServer}' but the query cache is on '{cacheServer}'. " + - "This breakdown joins on the cache server, so they must be the same server."); + && !string.Equals(cacheServer.Trim(), demogServer.Trim(), StringComparison.OrdinalIgnoreCase)) + return Fail($"Region column is on server '{demogServer}' but the query cache is on '{cacheServer}'; " + + "the breakdown joins on the cache server, so they must be the same server."); + + return true; + } + + private T SelectOne(string prompt, T[] available) where T : class => + available.Length > 0 && BasicActivator.SelectObject(prompt, available, out var selected) ? selected : null; + + private bool Fail(string reason) + { + BasicActivator.Show(reason); + return false; } public override void Execute() { base.Execute(); + if (!ResolveInputs()) + return; + _toFile ??= BasicActivator.IsInteractive - ? BasicActivator.SelectFile("Path to write build health board breakdown to", "Build health board breakdown", "*.csv") - : new FileInfo(Path.Combine(System.Environment.CurrentDirectory, $"{Sanitise(_cic.Name)}-build-healthboard.csv")); + ? BasicActivator.SelectFile("Path to write build breakdown to", "Build breakdown", "*.csv") + : new FileInfo(Path.Combine(Environment.CurrentDirectory, $"{Sanitise(_cic.Name)}-build-breakdown.csv")); if (_toFile == null) return; + _syntax = _regionColumn.GetQuerySyntaxHelper(); _cacheDb = _cic.QueryCachingServer.Discover(DataAccessContext.InternalDataProcessing); _cacheManager = new CachedAggregateConfigurationResultsManager(_cic.QueryCachingServer); - _demogTable = _idEi.ColumnInfo.TableInfo.Name; - _demogId = _idEi.GetRuntimeName(); - _regionName = _regionEi.GetRuntimeName(); + _demogTable = _regionColumn.TableInfo.Name; + _demogId = _syntax.EnsureWrapped(_idEi.GetRuntimeName()); + _regionName = _syntax.EnsureWrapped(_regionColumn.GetRuntimeName()); + + // load the (user-defined) region -> name/node mapping from the lookup table + _lookup = RegionLookup.LoadFrom(_groupLookup.Discover(DataAccessContext.InternalDataProcessing), _timeout); // 1. Build once: populates every per-set cache table and gives the baseline (unfiltered) counts. var compiler = new CohortCompiler(BasicActivator, _cic) { IncludeCumulativeTotals = true }; @@ -167,32 +182,30 @@ public override void Execute() }; if (isContainer == null || task.Child == null) continue; - _baseline[(isContainer.Value, task.Child.ID)] = - (task.FinalRowCount, task.CumulativeRowCount); + _baseline[(isContainer.Value, task.Child.ID)] = (task.FinalRowCount, task.CumulativeRowCount); } // 2. Walk the tree, recomposing each count point from the cache and splitting by region. - var nodes = new List(); + var nodes = new List(); var seq = 0; Walk(_cic.RootCohortAggregateContainer, null, 0, nodes, ref seq); - // reference: each board's share of the WHOLE demography population (a sanity check row) + // reference: each region's share of the WHOLE demography population (a sanity-check row) var demographyReference = ComputeDemographyReference(); - CohortBuildHealthBoardBreakdownReport.WriteCsv(_toFile.FullName, nodes, demographyReference); + CohortBuildHealthBoardBreakdownReport.WriteCsv(_toFile.FullName, nodes, _lookup, demographyReference); - // reconciliation note + // reconciliation note: recognised-region counts should never exceed the unfiltered total var drift = nodes.Count(n => - n.FinalByRegion.Where(kv => HealthBoardLookup.Resolve(kv.Key).Node != HealthBoardLookup.UnknownNode) - .Sum(kv => kv.Value) > n.FinalUnfiltered); - var summary = $"Exported build health board breakdown to {_toFile.FullName} ({nodes.Count} count points)"; + n.FinalByRegion.Where(kv => _lookup.Contains(kv.Key)).Sum(kv => kv.Value) > n.FinalUnfiltered); + var summary = $"Exported build breakdown to {_toFile.FullName} ({nodes.Count} count points)"; if (drift > 0) - summary += $" - WARNING: {drift} node(s) have board counts exceeding the unfiltered total (check demography keys)"; + summary += $" - WARNING: {drift} node(s) have region counts exceeding the unfiltered total (check keys)"; BasicActivator.Show(summary); } private void Walk(CohortAggregateContainer container, CohortAggregateContainer parent, int indexInParent, - List nodes, ref int seq) + List nodes, ref int seq) { // container node row (cumulative is within its parent) var (cFinal, cCum) = _baseline.TryGetValue((true, container.ID), out var cb) ? cb : (0, null); @@ -200,19 +213,10 @@ private void Walk(CohortAggregateContainer container, CohortAggregateContainer p if (parent != null && indexInParent > 0 && cCum.HasValue) cCumByRegion = RunRegionCounts(CumulativeSql(parent, indexInParent)); - nodes.Add(new CohortBuildHealthBoardBreakdownReport.NodeBreakdown - { - Seq = seq++, - Type = "Container", - Name = CleanName(container.Name), - Container = CleanName(parent?.Name), - SetOperation = container.Operation.ToString(), - DisplayOrder = container.Order, - FinalUnfiltered = cFinal, - CumulativeUnfiltered = parent != null && indexInParent > 0 ? cCum : null, - FinalByRegion = RunRegionCounts(IdSql(container)), - CumulativeByRegion = cCumByRegion - }); + nodes.Add(new CohortBuildBreakdownNode(seq++, "Container", CleanName(container.Name), + CleanName(parent?.Name), container.Operation.ToString(), container.Order, cFinal, + parent != null && indexInParent > 0 ? cCum : null, + RunRegionCounts(IdSql(container)), cCumByRegion)); var kids = EnabledOrdered(container); for (var i = 0; i < kids.Count; i++) @@ -225,19 +229,9 @@ private void Walk(CohortAggregateContainer container, CohortAggregateContainer p if (i > 0 && aCum.HasValue) aCumByRegion = RunRegionCounts(CumulativeSql(container, i)); - nodes.Add(new CohortBuildHealthBoardBreakdownReport.NodeBreakdown - { - Seq = seq++, - Type = "Cohort Set", - Name = CleanName(agg.Name), - Container = CleanName(container.Name), - SetOperation = "", - DisplayOrder = agg.Order, - FinalUnfiltered = aFinal, - CumulativeUnfiltered = i > 0 ? aCum : null, - FinalByRegion = RunRegionCounts(CachedSetSql(agg)), - CumulativeByRegion = aCumByRegion - }); + nodes.Add(new CohortBuildBreakdownNode(seq++, "Cohort Set", CleanName(agg.Name), + CleanName(container.Name), "", agg.Order, aFinal, i > 0 ? aCum : null, + RunRegionCounts(CachedSetSql(agg)), aCumByRegion)); break; case CohortAggregateContainer sub: @@ -253,7 +247,7 @@ private void Walk(CohortAggregateContainer container, CohortAggregateContainer p { AggregateConfiguration agg => CachedSetSql(agg), CohortAggregateContainer c => Compose(c, EnabledOrdered(c)), - _ => throw new System.NotSupportedException(node.GetType().Name) + _ => throw new NotSupportedException(node.GetType().Name) }; private string CumulativeSql(CohortAggregateContainer container, int upToInclusive) => @@ -261,7 +255,7 @@ private string CumulativeSql(CohortAggregateContainer container, int upToInclusi private string Compose(CohortAggregateContainer container, IReadOnlyList children) { - var op = $"\n{container.Operation}\n"; // UNION / INTERSECT / EXCEPT are valid SQL Server keywords + var op = $"\n{container.Operation}\n"; // UNION / INTERSECT / EXCEPT (the operators RDMP itself uses) return string.Join(op, children.Select(ch => $"({IdSql(ch)})")); } @@ -272,13 +266,13 @@ private string CachedSetSql(AggregateConfiguration agg) var table = _cacheManager.GetLatestResultsTableUnsafe(agg, AggregateOperation.IndexedExtractionIdentifierList) as DiscoveredTable; if (table == null) - throw new System.Exception($"Cohort set '{agg.Name}' has no cached identifier list - the build did not cache it"); + throw new Exception($"Cohort set '{agg.Name}' has no cached identifier list - the build did not cache it"); var col = table.DiscoverColumns()[0].GetRuntimeName(); - t = (table.GetFullyQualifiedName(), col); + t = (table.GetFullyQualifiedName(), _syntax.EnsureWrapped(col)); _setCacheTable[agg.ID] = t; } - return $"SELECT {t.col} AS id FROM {t.fqn}"; + return $"SELECT {t.col} id FROM {t.fqn}"; } private List EnabledOrdered(CohortAggregateContainer container) => @@ -294,57 +288,48 @@ private List EnabledOrdered(CohortAggregateContainer container) => private IReadOnlyDictionary RunRegionCounts(string idListSql) { var sql = - $"SELECT d.[{_regionName}] AS Region, COUNT(DISTINCT i.id) AS n\n" + + $"SELECT d.{_regionName} region, COUNT(DISTINCT i.id) n\n" + $"FROM (\n{idListSql}\n) i\n" + - $"INNER JOIN {_demogTable} d ON d.[{_demogId}] = i.id\n" + - $"GROUP BY d.[{_regionName}]"; - - var result = new Dictionary(System.StringComparer.OrdinalIgnoreCase); - using var con = _cacheDb.Server.GetConnection(); - con.Open(); - using var cmd = _cacheDb.Server.GetCommand(sql, con); - cmd.CommandTimeout = _timeout; - using var r = cmd.ExecuteReader(); - while (r.Read()) - { - if (r["Region"] == System.DBNull.Value) - continue; // NULL region folds into Unknown via baseline subtraction - result[r["Region"].ToString()] = System.Convert.ToInt32(r["n"]); - } + $"INNER JOIN {_demogTable} d ON d.{_demogId} = i.id\n" + + $"GROUP BY d.{_regionName}"; - return result; + return ReadRegionCounts(sql, out _); } /// /// The whole demography population split by region (the reference/background distribution). Total - /// includes NULL-region rows so - /// captures them. + /// includes NULL-region rows so captures them. /// - private CohortBuildHealthBoardBreakdownReport.Buckets ComputeDemographyReference() + private CohortBuildBreakdownBuckets ComputeDemographyReference() { var sql = - $"SELECT d.[{_regionName}] AS Region, COUNT(DISTINCT d.[{_demogId}]) AS n\n" + + $"SELECT d.{_regionName} region, COUNT(DISTINCT d.{_demogId}) n\n" + $"FROM {_demogTable} d\n" + - $"GROUP BY d.[{_regionName}]"; + $"GROUP BY d.{_regionName}"; - var byRegion = new Dictionary(System.StringComparer.OrdinalIgnoreCase); - var total = 0; - using (var con = _cacheDb.Server.GetConnection()) + var byRegion = ReadRegionCounts(sql, out var total); + return CohortBuildHealthBoardBreakdownReport.Split(total, byRegion, _lookup); + } + + /// Runs a "region, count" query on the cache server; returns non-null regions and the grand total. + private Dictionary ReadRegionCounts(string sql, out int total) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + total = 0; + using var con = _cacheDb.Server.GetConnection(); + con.Open(); + using var cmd = _cacheDb.Server.GetCommand(sql, con); + cmd.CommandTimeout = _timeout; + using var r = cmd.ExecuteReader(); + while (r.Read()) { - con.Open(); - using var cmd = _cacheDb.Server.GetCommand(sql, con); - cmd.CommandTimeout = _timeout; - using var r = cmd.ExecuteReader(); - while (r.Read()) - { - var n = System.Convert.ToInt32(r["n"]); - total += n; // includes the NULL-region group in the denominator - if (r["Region"] != System.DBNull.Value) - byRegion[r["Region"].ToString()] = n; - } + var n = Convert.ToInt32(r.GetValue(1)); + total += n; // includes any NULL-region group in the denominator + if (!r.IsDBNull(0)) + result[r.GetValue(0).ToString()] = n; } - return CohortBuildHealthBoardBreakdownReport.Split(total, byRegion); + return result; } // RDMP prefixes cohort set names with "cic__" (EnsureNamingConvention); cloning a cohort across diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/HealthBoardLookup.cs b/RdmpCohortBuildHealthBoardBreakdown/src/HealthBoardLookup.cs deleted file mode 100644 index a0a2a67ff5..0000000000 --- a/RdmpCohortBuildHealthBoardBreakdown/src/HealthBoardLookup.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) The University of Dundee 2024-2024 -// This file is part of the Research Data Management Platform (RDMP). -// RDMP is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. -// RDMP is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. -// You should have received a copy of the GNU General Public License along with RDMP. If not, see . - -using System.Collections.Generic; - -namespace Rdmp.Core.CohortCreation; - -/// -/// A Scottish health board: the single-letter cipher held in -/// SHARE_Demography, its numeric (null for legacy boards), its display -/// , and the safe-haven it rolls up to. -/// -public sealed record HealthBoard(string Region, int? HbCode, string Name, string Node); - -/// -/// Hardcoded mapping from a SHARE_Demography Region cipher to its health board and -/// safe-haven node. Single source of truth for the cohort health-board breakdown report; an -/// unrecognised or NULL region resolves to a non-null "(unknown)" board under the -/// so counts are never silently dropped. -/// -public static class HealthBoardLookup -{ - /// Node assigned to any region cipher not present in the lookup (or NULL/empty). - public const string UnknownNode = "Unknown"; - - // keyed by the single-letter Region cipher held in SHARE_Demography.Region - private static readonly Dictionary ByRegion = new(System.StringComparer.OrdinalIgnoreCase) - { - ["A"] = new("A", 11, "Ayrshire & Arran", "West"), - ["B"] = new("B", 6, "Borders", "South East"), - ["Y"] = new("Y", 12, "Dumfries & Galloway", "West"), - ["F"] = new("F", 4, "Fife", "East"), - ["V"] = new("V", 7, "Forth Valley", "East"), - ["N"] = new("N", 2, "Grampian", "North"), - ["G"] = new("G", 16, "Greater Glasgow & Clyde", "West"), - ["H"] = new("H", 17, "Highland", "North"), - ["L"] = new("L", 10, "Lanarkshire", "West"), - ["S"] = new("S", 5, "Lothian", "South East"), - ["R"] = new("R", 13, "Orkney", "North"), - ["Z"] = new("Z", 14, "Shetland", "North"), - ["T"] = new("T", 3, "Tayside", "East"), - ["W"] = new("W", 15, "Western Isles", "North"), - ["C"] = new("C", null, "Clyde", "West"), // legacy board: no numeric HB_Code (intentional) - }; - - /// - /// Resolves a Region cipher to its . Unknown, NULL or empty - /// ciphers map to a placeholder board (name "(unknown)", node ) - /// rather than null, so unmapped patients are reported and reconcile to the cohort total. - /// - public static HealthBoard Resolve(string region) - { - var key = region?.Trim(); - return !string.IsNullOrEmpty(key) && ByRegion.TryGetValue(key, out var hb) - ? hb - : new HealthBoard(key ?? "", null, "(unknown)", UnknownNode); - } -} diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/RegionLookup.cs b/RdmpCohortBuildHealthBoardBreakdown/src/RegionLookup.cs new file mode 100644 index 0000000000..f8c290ce45 --- /dev/null +++ b/RdmpCohortBuildHealthBoardBreakdown/src/RegionLookup.cs @@ -0,0 +1,77 @@ +// Copyright (c) The University of Dundee 2024-2024 +// This file is part of the Research Data Management Platform (RDMP). +// RDMP is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. +// RDMP is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +// You should have received a copy of the GNU General Public License along with RDMP. If not, see . + +using System; +using System.Collections.Generic; +using FAnsi.Discovery; + +namespace Rdmp.Core.CohortCreation; + +/// +/// A user-defined mapping from a region code to a display name and a grouping node, loaded at runtime +/// from a lookup table (rather than hard-coded). The lookup table is expected to have the columns named +/// by , and ; a NULL +/// is allowed (e.g. non-Scottish / administrative codes that still have a name +/// but no safe-haven node). Codes absent from the table are treated as unmapped ("Other"). +/// +public sealed class RegionLookup +{ + /// Column holding the region code that appears in the demography data. + public const string RegionColumn = "Region"; + + /// Column holding the display name for a region code. + public const string NameColumn = "HB_Name"; + + /// Column holding the grouping node (used to order columns); may be NULL. + public const string NodeColumn = "SafeHaven_Region"; + + private readonly Dictionary _map; + + public RegionLookup(IReadOnlyDictionary map) => + _map = new Dictionary(map, StringComparer.OrdinalIgnoreCase); + + /// True if the code is present in the lookup (a recognised region). + public bool Contains(string code) => code != null && _map.ContainsKey(code.Trim()); + + /// Display name for the code, or null if unmapped. + public string NameOf(string code) => + code != null && _map.TryGetValue(code.Trim(), out var v) ? v.Name : null; + + /// Grouping node for the code (may be null even for a mapped code), or null if unmapped. + public string NodeOf(string code) => + code != null && _map.TryGetValue(code.Trim(), out var v) ? v.Node : null; + + /// + /// Reads the mapping from a lookup (columns / + /// / ). Rows with a NULL/blank region code are skipped; + /// a NULL name falls back to the code; a NULL node is preserved. + /// + public static RegionLookup LoadFrom(DiscoveredTable table, int timeout) + { + var syntax = table.Database.Server.GetQuerySyntaxHelper(); + var sql = + $"SELECT {syntax.EnsureWrapped(RegionColumn)}, {syntax.EnsureWrapped(NameColumn)}, " + + $"{syntax.EnsureWrapped(NodeColumn)} FROM {table.GetFullyQualifiedName()}"; + + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + using var con = table.Database.Server.GetConnection(); + con.Open(); + using var cmd = table.Database.Server.GetCommand(sql, con); + cmd.CommandTimeout = timeout; + using var r = cmd.ExecuteReader(); + while (r.Read()) + { + var code = r[0] == DBNull.Value ? null : r[0].ToString()?.Trim(); + if (string.IsNullOrEmpty(code)) + continue; + var name = r[1] == DBNull.Value ? code : r[1].ToString(); + var node = r[2] == DBNull.Value ? null : r[2].ToString(); + map[code] = (name, node); + } + + return new RegionLookup(map); + } +} From bf7b7aebad53b634df134b2e598c7a7f438c2cda Mon Sep 17 00:00:00 2001 From: mtinti Date: Fri, 10 Jul 2026 10:22:32 +0100 Subject: [PATCH 06/16] Render set operations per-DBMS (Oracle EXCEPT -> MINUS) - package refresh Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0169JCnaL3fhhZjseDx2XXT2 --- .../RdmpCohortBuildHealthBoardBreakdown.rdmp | Bin 15955 -> 16123 bytes ...ndExportCohortBuildHealthBoardBreakdown.cs | 15 ++++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp b/RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp index b603a3a903ef901af7d15ae61ab78fed70614b0c..35b27ee104e06b7bed1bb3538342daa0ad4f318a 100644 GIT binary patch delta 15172 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 b`~M?qT@g_H-!Ak7AweW41A+fkv)}$7jUT{1 delta 15024 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 diff --git a/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs b/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs index 7aa2300d28..2395de8e62 100644 --- a/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs +++ b/RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading; +using FAnsi; using FAnsi.Discovery; using FAnsi.Discovery.QuerySyntax; using Rdmp.Core.CohortCreation; @@ -255,10 +256,22 @@ private string CumulativeSql(CohortAggregateContainer container, int upToInclusi private string Compose(CohortAggregateContainer container, IReadOnlyList children) { - var op = $"\n{container.Operation}\n"; // UNION / INTERSECT / EXCEPT (the operators RDMP itself uses) + var op = $"\n{SetOperationSql(container.Operation, _syntax.DatabaseType)}\n"; return string.Join(op, children.Select(ch => $"({IdSql(ch)})")); } + /// + /// Renders a container's set operation for the target DBMS (Oracle spells EXCEPT as MINUS) - the + /// same mapping RDMP uses in CohortQueryBuilderResult.GetSetOperationSql. + /// + public static string SetOperationSql(SetOperation operation, DatabaseType dbType) => operation switch + { + SetOperation.UNION => "UNION", + SetOperation.INTERSECT => "INTERSECT", + SetOperation.EXCEPT => dbType == DatabaseType.Oracle ? "MINUS" : "EXCEPT", + _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null) + }; + private string CachedSetSql(AggregateConfiguration agg) { if (!_setCacheTable.TryGetValue(agg.ID, out var t)) From 2f104f8985338aef48e0d38437d767d26ed4af06 Mon Sep 17 00:00:00 2001 From: mtinti Date: Tue, 14 Jul 2026 19:37:09 +0100 Subject: [PATCH 07/16] Rename package to RdmpCohortBuildBreakdownByGroups (fully generic + SHARE preset) Address the review's "make it more generic" comment fully: the command is now ExportCohortBuildBreakDownByGroups taking four ColumnInfo inputs (group-by column + lookup key/label/optional grouping; the reference and lookup tables are derived from the columns and the patient identifier is the reference table's single IsExtractionIdentifier column). GroupLookup loads caller-named columns (nothing hard-coded in the engine); the SHARE names live only in SharePreset.cs, which the GUI uses for a one-click "(SHARE preset)" menu entry alongside "(choose inputs)". Package folder, plugin id, .rdmp, README and INSTALL renamed to match; "% of demography" row renamed "% of reference population". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0169JCnaL3fhhZjseDx2XXT2 --- RdmpCohortBuildBreakdownByGroups/INSTALL.md | 70 ++++++ RdmpCohortBuildBreakdownByGroups/README.md | 55 +++++ .../RdmpCohortBuildBreakdownByGroups.rdmp | Bin 0 -> 17410 bytes ...ildBreakdownByGroupsPluginUserInterface.cs | 39 ++++ .../src/CohortBuildBreakdownByGroupsReport.cs | 80 +++---- .../src/CohortBuildBreakdownModels.cs | 26 +-- ...mmandExportCohortBuildBreakDownByGroups.cs | 210 ++++++++++-------- .../src/GroupLookup.cs | 69 ++++++ .../RdmpCohortBuildBreakdownByGroups.csproj | 2 +- .../RdmpCohortBuildBreakdownByGroups.nuspec | 11 +- .../src/SharePreset.cs | 59 +++++ .../INSTALL.md | 62 ------ RdmpCohortBuildHealthBoardBreakdown/README.md | 46 ---- .../RdmpCohortBuildHealthBoardBreakdown.rdmp | Bin 16123 -> 0 bytes ...HealthBoardBreakdownPluginUserInterface.cs | 24 -- .../src/RegionLookup.cs | 77 ------- 16 files changed, 472 insertions(+), 358 deletions(-) create mode 100644 RdmpCohortBuildBreakdownByGroups/INSTALL.md create mode 100644 RdmpCohortBuildBreakdownByGroups/README.md create mode 100644 RdmpCohortBuildBreakdownByGroups/RdmpCohortBuildBreakdownByGroups.rdmp create mode 100644 RdmpCohortBuildBreakdownByGroups/src/CohortBuildBreakdownByGroupsPluginUserInterface.cs rename RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownReport.cs => RdmpCohortBuildBreakdownByGroups/src/CohortBuildBreakdownByGroupsReport.cs (63%) rename {RdmpCohortBuildHealthBoardBreakdown => RdmpCohortBuildBreakdownByGroups}/src/CohortBuildBreakdownModels.cs (74%) rename RdmpCohortBuildHealthBoardBreakdown/src/ExecuteCommandExportCohortBuildHealthBoardBreakdown.cs => RdmpCohortBuildBreakdownByGroups/src/ExecuteCommandExportCohortBuildBreakDownByGroups.cs (58%) create mode 100644 RdmpCohortBuildBreakdownByGroups/src/GroupLookup.cs rename RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.csproj => RdmpCohortBuildBreakdownByGroups/src/RdmpCohortBuildBreakdownByGroups.csproj (92%) rename RdmpCohortBuildHealthBoardBreakdown/src/RdmpCohortBuildHealthBoardBreakdown.nuspec => RdmpCohortBuildBreakdownByGroups/src/RdmpCohortBuildBreakdownByGroups.nuspec (50%) create mode 100644 RdmpCohortBuildBreakdownByGroups/src/SharePreset.cs delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/INSTALL.md delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/README.md delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/RdmpCohortBuildHealthBoardBreakdown.rdmp delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/CohortBuildHealthBoardBreakdownPluginUserInterface.cs delete mode 100644 RdmpCohortBuildHealthBoardBreakdown/src/RegionLookup.cs diff --git a/RdmpCohortBuildBreakdownByGroups/INSTALL.md b/RdmpCohortBuildBreakdownByGroups/INSTALL.md new file mode 100644 index 0000000000..714c1b2543 --- /dev/null +++ b/RdmpCohortBuildBreakdownByGroups/INSTALL.md @@ -0,0 +1,70 @@ +# RdmpCohortBuildBreakdownByGroups plugin (RDMP 9.2.3) + +Reproduces the Cohort Builder's per-set / per-container count tree (the FinalCount and cumulative +running totals shown as UNION/INTERSECT/EXCEPT are applied) **split by an arbitrary group column** +(e.g. Scottish health board), labelled and ordered by a user-supplied lookup table. Saved as a wide CSV. + +Built against the **released RDMP 9.2.3**. Do not use on a different major.minor RDMP. + +## How it works (cache-only, cross-server safe) + +It builds the cohort **once** (populating the query cache), then recomposes every count point from the +cached per-set identifier tables and splits each by the group column with one GROUP BY per node. It +never re-runs the source catalogues per group, and never touches the source servers after the single +build, only the query-cache server (which is why the reference table must be on the same server as the +query cache). Nothing is hard-coded in the engine; the SHARE names live only in the plugin's preset. + +## Inputs (4 columns; the tables are derived) + +- **group column** - the column to break the counts down by (e.g. `SHARE_Demography.Region`). Its table + is the reference table and must contain exactly one IsExtractionIdentifier column (the CHI), which is + the join key to the cohort. +- **lookup key column** - the group code as it appears in the group column (e.g. `z_hb_lookup.Region`). + Its table is the lookup table. +- **lookup label column** - the display name per code (e.g. `z_hb_lookup.HB_Name`). +- **lookup grouping column** (optional) - a higher grouping used to order the output columns (e.g. + `z_hb_lookup.SafeHaven_Region`; NULL values allowed). + +Codes present in the data but absent from the lookup go to `Other`; patients missing from the reference +table (or with a NULL group) go to `NotKnown`. + +## Requirements + +- The cohort identification configuration must have a **query caching server** configured. +- The reference table must be on the **same SQL server as the query cache** (checked; refuses if not). + +## Install + +**GUI:** RDMP desktop, Plugins node, *Add Plugin* (or drag `RdmpCohortBuildBreakdownByGroups.rdmp` onto +it), restart RDMP. **Or** drop the `.rdmp` next to `rdmp.exe` / `ResearchDataManagementPlatform.exe`. + +Confirm (CLI): `rdmp.exe cmd ListSupportedCommands` lists `ExportCohortBuildBreakDownByGroups`. + +## Use + +**GUI:** right-click a Cohort Identification Configuration. Two entries: +- *Export Build Breakdown By Groups (SHARE preset)* - resolves `SHARE_Demography`.`Region` and + `z_hb_lookup`.`Region`/`HB_Name`/`SafeHaven_Region` by name; prompts only for anything not found. +- *Export Build Breakdown By Groups (choose inputs)* - prompts for all four columns. + +**CLI:** the inputs are RDMP objects, mapped by id: +``` +rdmp.exe cmd ExportCohortBuildBreakDownByGroups \ + CohortIdentificationConfiguration: ColumnInfo: ColumnInfo: ColumnInfo: