health-code 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- health_code-0.1.0/PKG-INFO +135 -0
- health_code-0.1.0/README.md +126 -0
- health_code-0.1.0/pyproject.toml +28 -0
- health_code-0.1.0/src/health/__init__.py +19 -0
- health_code-0.1.0/src/health/cli.py +35 -0
- health_code-0.1.0/src/health/disclosure.py +331 -0
- health_code-0.1.0/src/health/examples/prepare_mortality_disclosure.py +94 -0
- health_code-0.1.0/src/health/helpers.py +30 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: health-code
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small helpers for preparing rounded, masked mortality tables for disclosure review.
|
|
5
|
+
Requires-Dist: polars>=1.36.1,<1.42
|
|
6
|
+
Requires-Dist: xlsxwriter>=3.2,<4
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# health-code
|
|
11
|
+
|
|
12
|
+
Small helpers for preparing a rounded, masked mortality table for disclosure review.
|
|
13
|
+
Install the distribution `health-code`; import it as `health`.
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from health import print_unique # also: from health.helpers import print_unique
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`print_unique` is copied, with the same signature and behavior, from the LTC code package
|
|
20
|
+
(`ltc_code.helpers.print_unique`) so you do not need to install LTC.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
From PyPI:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv pip install health-code
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
From a local wheel:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv build # writes dist/health_code-0.1.0-py3-none-any.whl
|
|
34
|
+
uv pip install dist/health_code-0.1.0-py3-none-any.whl
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Python 3.9 or newer. Dependencies: polars, xlsxwriter.
|
|
38
|
+
|
|
39
|
+
## The example
|
|
40
|
+
|
|
41
|
+
After installing, copy the example script next to your work (it refuses to overwrite):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
health-example my_scripts/ # or: health-example my_scripts/prepare_table.py
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Edit the `Settings` block at the top and run it. It does this:
|
|
48
|
+
|
|
49
|
+
1. Reads one parquet per year, 2001..2024, from `INPUT_FOLDER`. The file name comes from
|
|
50
|
+
`FILENAME_PATTERN`, e.g. `mortality_{year}.parquet`. All 24 files must exist.
|
|
51
|
+
2. Keeps only the eight columns in `COLUMN_MAP` and renames them to `year`, `sex`,
|
|
52
|
+
`income_percentile`, `age`, `mortality_rate`, `deaths`, `n`, `n_weighted`. Replace the
|
|
53
|
+
`*_placeholder` names with your real column names.
|
|
54
|
+
3. Fails on: a missing file, a missing column, different column types across files, a row whose
|
|
55
|
+
year is not its file's year, missing values, duplicate keys, ages outside `AGES`, a rate outside
|
|
56
|
+
0..1, `deaths > n`, `n <= 0`, or non-positive `n_weighted`.
|
|
57
|
+
4. Rounds and masks (below) and writes the request folder.
|
|
58
|
+
|
|
59
|
+
You must set `RATE_KIND` (`"weighted"` or `"unweighted"`) and `REQUEST_DATE` (`YYYYMMDD`). Neither
|
|
60
|
+
has a default, because a wrong guess would change what is released.
|
|
61
|
+
|
|
62
|
+
The same steps are available as functions: `read_annual_parquets`, `prepare_disclosure`,
|
|
63
|
+
`write_disclosure`, and `round_count`.
|
|
64
|
+
|
|
65
|
+
## What gets written
|
|
66
|
+
|
|
67
|
+
Layout follows the FSRDC Disclosure Avoidance Review Procedures Handbook v7.0.0 (2026-08-03),
|
|
68
|
+
section III.C:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
YYYYMMDD/
|
|
72
|
+
output/Table_1_T13T26.xlsx the file to submit (rounded, masked)
|
|
73
|
+
support/Table_1_support_CUI_SP-CENS_T13T26.csv exact combined data
|
|
74
|
+
support/estimate_inventory_T13T26.xlsx row and estimate counts
|
|
75
|
+
support/rounding/README.txt how to run the official rounder
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- Every xlsx has a first sheet named `CUI` holding only the CUI header text.
|
|
79
|
+
- The release is xlsx only, so the same estimates are not submitted in two formats. For a CSV copy
|
|
80
|
+
(for your own use), set `RELEASE_CSV_FOLDER` to a folder outside the request folder.
|
|
81
|
+
- The support CSV holds exact counts, survivors and the suppression flags. It is for the `support`
|
|
82
|
+
folder only. Only the eight mapped columns ever reach any output file.
|
|
83
|
+
- `estimate_inventory_T13T26.xlsx` leaves prior releases as `UNKNOWN`; fill them in from your
|
|
84
|
+
project's output tracking sheet.
|
|
85
|
+
- Existing files are never replaced unless `OVERWRITE = True`. Nothing is deleted.
|
|
86
|
+
- `support/rounding/` is left for you: run the Census program `drb_rounder.py` following the DRB
|
|
87
|
+
Rounder Tutorial on RDC Docs and keep its `_rounded.xlsx` output. This package does not fake it.
|
|
88
|
+
|
|
89
|
+
## Rounding and masking rules
|
|
90
|
+
|
|
91
|
+
From the FSRDC Disclosure Avoidance Methods Handbook (guidance current as of 2026-05-01),
|
|
92
|
+
section V.B. Each count is rounded by **its own size**:
|
|
93
|
+
|
|
94
|
+
| count | rounded to |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| under 15 | suppressed |
|
|
97
|
+
| 15 to 99 | nearest 10 |
|
|
98
|
+
| 100 to 999 | nearest 50 |
|
|
99
|
+
| 1,000 to 9,999 | nearest 100 |
|
|
100
|
+
| 10,000 to 99,999 | nearest 500 |
|
|
101
|
+
| 100,000 to 999,999 | nearest 1,000 |
|
|
102
|
+
| 1,000,000 or more | four significant figures |
|
|
103
|
+
|
|
104
|
+
Exact ties: counts below 1,000,000 round half to even (25 -> 20, as in
|
|
105
|
+
`oi_tools.drb.round_count_column`); four-significant-figure rounding at 1,000,000 or more rounds
|
|
106
|
+
ties away from zero (1,234,500 -> 1,235,000). The handbook does not specify tie-breaking, so run
|
|
107
|
+
the official Census rounder before release. Weighted `n_weighted` is rounded to four significant
|
|
108
|
+
figures.
|
|
109
|
+
|
|
110
|
+
- `"unweighted"`: released rate = rounded `deaths` / rounded `n`, to four significant figures.
|
|
111
|
+
- `"weighted"`: released rate = your input rate to four significant figures. Deaths are never rebuilt
|
|
112
|
+
from a weighted rate.
|
|
113
|
+
|
|
114
|
+
**Suppression (a chosen conservative policy, not a universal Census minimum).** A row's four numbers
|
|
115
|
+
(`n`, `deaths`, `mortality_rate`, `n_weighted`) are all set to blank when `n < 15`, `deaths < 15`,
|
|
116
|
+
`n - deaths < 15`, or an optional `EXTRA_SUPPRESS` condition is true. Zeros are suppressed too.
|
|
117
|
+
Keys stay, so the table keeps its shape, and the release has no column saying why a row is blank.
|
|
118
|
+
|
|
119
|
+
This package is not an approval. It does not check prior releases, the 30:1 volume rule, the
|
|
120
|
+
required minimum entity counts for your geography, or anything the disclosure officer reviews.
|
|
121
|
+
Run the official `drb_rounder.py` and follow your FSRDC administrator's current instructions.
|
|
122
|
+
|
|
123
|
+
Why not depend on `oi-tools`: it requires Python 3.10 or newer and this package targets 3.9. The
|
|
124
|
+
count rounding is a few lines of polars, and the tests check its thresholds against an independent
|
|
125
|
+
reference. `round_proportion_column` in `oi-tools` is not used (outdated rule).
|
|
126
|
+
|
|
127
|
+
## Develop
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
uv sync
|
|
131
|
+
uv run python -m unittest discover -s tests -v
|
|
132
|
+
uv build
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Tests use synthetic data only.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# health-code
|
|
2
|
+
|
|
3
|
+
Small helpers for preparing a rounded, masked mortality table for disclosure review.
|
|
4
|
+
Install the distribution `health-code`; import it as `health`.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from health import print_unique # also: from health.helpers import print_unique
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`print_unique` is copied, with the same signature and behavior, from the LTC code package
|
|
11
|
+
(`ltc_code.helpers.print_unique`) so you do not need to install LTC.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
From PyPI:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
uv pip install health-code
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
From a local wheel:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv build # writes dist/health_code-0.1.0-py3-none-any.whl
|
|
25
|
+
uv pip install dist/health_code-0.1.0-py3-none-any.whl
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Python 3.9 or newer. Dependencies: polars, xlsxwriter.
|
|
29
|
+
|
|
30
|
+
## The example
|
|
31
|
+
|
|
32
|
+
After installing, copy the example script next to your work (it refuses to overwrite):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
health-example my_scripts/ # or: health-example my_scripts/prepare_table.py
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Edit the `Settings` block at the top and run it. It does this:
|
|
39
|
+
|
|
40
|
+
1. Reads one parquet per year, 2001..2024, from `INPUT_FOLDER`. The file name comes from
|
|
41
|
+
`FILENAME_PATTERN`, e.g. `mortality_{year}.parquet`. All 24 files must exist.
|
|
42
|
+
2. Keeps only the eight columns in `COLUMN_MAP` and renames them to `year`, `sex`,
|
|
43
|
+
`income_percentile`, `age`, `mortality_rate`, `deaths`, `n`, `n_weighted`. Replace the
|
|
44
|
+
`*_placeholder` names with your real column names.
|
|
45
|
+
3. Fails on: a missing file, a missing column, different column types across files, a row whose
|
|
46
|
+
year is not its file's year, missing values, duplicate keys, ages outside `AGES`, a rate outside
|
|
47
|
+
0..1, `deaths > n`, `n <= 0`, or non-positive `n_weighted`.
|
|
48
|
+
4. Rounds and masks (below) and writes the request folder.
|
|
49
|
+
|
|
50
|
+
You must set `RATE_KIND` (`"weighted"` or `"unweighted"`) and `REQUEST_DATE` (`YYYYMMDD`). Neither
|
|
51
|
+
has a default, because a wrong guess would change what is released.
|
|
52
|
+
|
|
53
|
+
The same steps are available as functions: `read_annual_parquets`, `prepare_disclosure`,
|
|
54
|
+
`write_disclosure`, and `round_count`.
|
|
55
|
+
|
|
56
|
+
## What gets written
|
|
57
|
+
|
|
58
|
+
Layout follows the FSRDC Disclosure Avoidance Review Procedures Handbook v7.0.0 (2026-08-03),
|
|
59
|
+
section III.C:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
YYYYMMDD/
|
|
63
|
+
output/Table_1_T13T26.xlsx the file to submit (rounded, masked)
|
|
64
|
+
support/Table_1_support_CUI_SP-CENS_T13T26.csv exact combined data
|
|
65
|
+
support/estimate_inventory_T13T26.xlsx row and estimate counts
|
|
66
|
+
support/rounding/README.txt how to run the official rounder
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- Every xlsx has a first sheet named `CUI` holding only the CUI header text.
|
|
70
|
+
- The release is xlsx only, so the same estimates are not submitted in two formats. For a CSV copy
|
|
71
|
+
(for your own use), set `RELEASE_CSV_FOLDER` to a folder outside the request folder.
|
|
72
|
+
- The support CSV holds exact counts, survivors and the suppression flags. It is for the `support`
|
|
73
|
+
folder only. Only the eight mapped columns ever reach any output file.
|
|
74
|
+
- `estimate_inventory_T13T26.xlsx` leaves prior releases as `UNKNOWN`; fill them in from your
|
|
75
|
+
project's output tracking sheet.
|
|
76
|
+
- Existing files are never replaced unless `OVERWRITE = True`. Nothing is deleted.
|
|
77
|
+
- `support/rounding/` is left for you: run the Census program `drb_rounder.py` following the DRB
|
|
78
|
+
Rounder Tutorial on RDC Docs and keep its `_rounded.xlsx` output. This package does not fake it.
|
|
79
|
+
|
|
80
|
+
## Rounding and masking rules
|
|
81
|
+
|
|
82
|
+
From the FSRDC Disclosure Avoidance Methods Handbook (guidance current as of 2026-05-01),
|
|
83
|
+
section V.B. Each count is rounded by **its own size**:
|
|
84
|
+
|
|
85
|
+
| count | rounded to |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| under 15 | suppressed |
|
|
88
|
+
| 15 to 99 | nearest 10 |
|
|
89
|
+
| 100 to 999 | nearest 50 |
|
|
90
|
+
| 1,000 to 9,999 | nearest 100 |
|
|
91
|
+
| 10,000 to 99,999 | nearest 500 |
|
|
92
|
+
| 100,000 to 999,999 | nearest 1,000 |
|
|
93
|
+
| 1,000,000 or more | four significant figures |
|
|
94
|
+
|
|
95
|
+
Exact ties: counts below 1,000,000 round half to even (25 -> 20, as in
|
|
96
|
+
`oi_tools.drb.round_count_column`); four-significant-figure rounding at 1,000,000 or more rounds
|
|
97
|
+
ties away from zero (1,234,500 -> 1,235,000). The handbook does not specify tie-breaking, so run
|
|
98
|
+
the official Census rounder before release. Weighted `n_weighted` is rounded to four significant
|
|
99
|
+
figures.
|
|
100
|
+
|
|
101
|
+
- `"unweighted"`: released rate = rounded `deaths` / rounded `n`, to four significant figures.
|
|
102
|
+
- `"weighted"`: released rate = your input rate to four significant figures. Deaths are never rebuilt
|
|
103
|
+
from a weighted rate.
|
|
104
|
+
|
|
105
|
+
**Suppression (a chosen conservative policy, not a universal Census minimum).** A row's four numbers
|
|
106
|
+
(`n`, `deaths`, `mortality_rate`, `n_weighted`) are all set to blank when `n < 15`, `deaths < 15`,
|
|
107
|
+
`n - deaths < 15`, or an optional `EXTRA_SUPPRESS` condition is true. Zeros are suppressed too.
|
|
108
|
+
Keys stay, so the table keeps its shape, and the release has no column saying why a row is blank.
|
|
109
|
+
|
|
110
|
+
This package is not an approval. It does not check prior releases, the 30:1 volume rule, the
|
|
111
|
+
required minimum entity counts for your geography, or anything the disclosure officer reviews.
|
|
112
|
+
Run the official `drb_rounder.py` and follow your FSRDC administrator's current instructions.
|
|
113
|
+
|
|
114
|
+
Why not depend on `oi-tools`: it requires Python 3.10 or newer and this package targets 3.9. The
|
|
115
|
+
count rounding is a few lines of polars, and the tests check its thresholds against an independent
|
|
116
|
+
reference. `round_proportion_column` in `oi-tools` is not used (outdated rule).
|
|
117
|
+
|
|
118
|
+
## Develop
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
uv sync
|
|
122
|
+
uv run python -m unittest discover -s tests -v
|
|
123
|
+
uv build
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Tests use synthetic data only.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "health-code"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Small helpers for preparing rounded, masked mortality tables for disclosure review."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"polars>=1.36.1,<1.42",
|
|
9
|
+
"xlsxwriter>=3.2,<4",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
health-example = "health.cli:main"
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = [
|
|
17
|
+
"openpyxl>=3.1",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["uv_build>=0.11.16,<0.12.0"]
|
|
22
|
+
build-backend = "uv_build"
|
|
23
|
+
|
|
24
|
+
[tool.uv.build-backend]
|
|
25
|
+
# The distribution is health-code on PyPI; the import name is health.
|
|
26
|
+
module-name = "health"
|
|
27
|
+
source-exclude = ["**/.DS_Store", "**/__pycache__/**", "**/*.pyc", "AUTH_STATUS.md", "BUILD_REPORT.md"]
|
|
28
|
+
wheel-exclude = ["**/.DS_Store", "**/__pycache__/**", "**/*.pyc"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Small helpers for preparing rounded, masked mortality tables for disclosure review."""
|
|
2
|
+
|
|
3
|
+
from health.disclosure import (
|
|
4
|
+
prepare_disclosure,
|
|
5
|
+
read_annual_parquets,
|
|
6
|
+
round_count,
|
|
7
|
+
write_disclosure,
|
|
8
|
+
)
|
|
9
|
+
from health.helpers import print_unique
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"print_unique",
|
|
15
|
+
"read_annual_parquets",
|
|
16
|
+
"prepare_disclosure",
|
|
17
|
+
"write_disclosure",
|
|
18
|
+
"round_count",
|
|
19
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""health-example PATH: copy the example script out of the installed package."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from importlib.resources import as_file, files
|
|
9
|
+
|
|
10
|
+
EXAMPLE_NAME = "prepare_mortality_disclosure.py"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv=None) -> int:
|
|
14
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
15
|
+
if len(argv) != 1:
|
|
16
|
+
print("usage: health-example PATH (a folder, or a new .py file name)")
|
|
17
|
+
return 2
|
|
18
|
+
raw = argv[0]
|
|
19
|
+
target = Path(raw).expanduser()
|
|
20
|
+
# A trailing separator means "folder", even if it does not exist yet (Path drops it).
|
|
21
|
+
if target.is_dir() or raw.endswith(("/", os.sep)) or (os.altsep and raw.endswith(os.altsep)):
|
|
22
|
+
target = target / EXAMPLE_NAME
|
|
23
|
+
if target.exists():
|
|
24
|
+
print(f"Refusing to overwrite {target}. Pick another name or delete it first.")
|
|
25
|
+
return 1
|
|
26
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
source = files("health") / "examples" / EXAMPLE_NAME
|
|
28
|
+
with as_file(source) as src:
|
|
29
|
+
shutil.copyfile(src, target)
|
|
30
|
+
print(f"Copied example to {target}")
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Read annual mortality parquets, validate them, round/mask them, and write a disclosure package.
|
|
2
|
+
|
|
3
|
+
Rounding follows the FSRDC Disclosure Avoidance Methods Handbook (guidance current as of
|
|
4
|
+
2026-05-01), section V.B. The count rounding matches oi_tools.drb.round_count_column
|
|
5
|
+
(oi-tools 0.14.1). It is reimplemented here with polars only because oi-tools needs
|
|
6
|
+
Python 3.10+ and this package targets 3.9+. tests/test_disclosure.py checks the thresholds.
|
|
7
|
+
|
|
8
|
+
This code is not an approval. The official drb_rounder.py check and the disclosure
|
|
9
|
+
officer's review still apply.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, Iterable, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import polars as pl
|
|
16
|
+
import xlsxwriter
|
|
17
|
+
|
|
18
|
+
# The canonical names every mapped input is renamed to.
|
|
19
|
+
KEY_COLUMNS = ["year", "sex", "income_percentile", "age"]
|
|
20
|
+
NUMERIC_COLUMNS = ["mortality_rate", "deaths", "n", "n_weighted"]
|
|
21
|
+
CANONICAL_COLUMNS = KEY_COLUMNS + NUMERIC_COLUMNS
|
|
22
|
+
|
|
23
|
+
# Chosen conservative policy (not a universal Census minimum): a row is masked entirely
|
|
24
|
+
# when n, deaths or n - deaths is below this. Zeros are suppressed too.
|
|
25
|
+
MIN_COUNT = 15
|
|
26
|
+
# Counts above this are rejected, so counts never lose precision as floats (2**53 is about 9e15).
|
|
27
|
+
MAX_COUNT = 10**12
|
|
28
|
+
EXCEL_MAX_DATA_ROWS = 1_048_575
|
|
29
|
+
|
|
30
|
+
CUI_TEXT = (
|
|
31
|
+
"CUI//SP-CENS. Disclosure Prohibited: T13 U.S.C. "
|
|
32
|
+
"Federal Tax Information Protected by Title 26 U.S.C."
|
|
33
|
+
)
|
|
34
|
+
CUI_TAG = "CUI_SP-CENS_T13T26"
|
|
35
|
+
|
|
36
|
+
ROUNDING_README = """\
|
|
37
|
+
This folder is for the official Census rounding check. It is empty on purpose.
|
|
38
|
+
|
|
39
|
+
Run the Census program drb_rounder.py on the xlsx in ../../output, following the
|
|
40
|
+
DRB Rounder Tutorial on RDC Docs (rdcdoc.cods.census.gov). It writes
|
|
41
|
+
"[output name]_rounded.xlsx". Keep that file as supporting evidence.
|
|
42
|
+
|
|
43
|
+
The health package rounds the table itself, but that is not the official check and
|
|
44
|
+
does not replace it.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def round_count(col) -> pl.Expr:
|
|
49
|
+
"""Round an unweighted count using its OWN size (Methods Handbook V.B.3).
|
|
50
|
+
|
|
51
|
+
<15 -> null, 15..99 -> nearest 10, 100..999 -> nearest 50, 1,000..9,999 -> nearest 100,
|
|
52
|
+
10,000..99,999 -> nearest 500, 100,000..999,999 -> nearest 1,000, >=1,000,000 -> four
|
|
53
|
+
significant figures. Exact ties round half to even below 1,000,000 (as oi_tools does) and
|
|
54
|
+
away from zero at 1,000,000 and above (1,234,500 -> 1,235,000), because polars
|
|
55
|
+
`round_sig_figs` is used there. The handbook does not specify ties; use the official
|
|
56
|
+
Census rounder for the release. Returns Int64.
|
|
57
|
+
"""
|
|
58
|
+
x = pl.col(col) if isinstance(col, str) else col
|
|
59
|
+
f = x.cast(pl.Float64)
|
|
60
|
+
near = lambda step: (f / step).round(0, mode="half_to_even") * step # noqa: E731
|
|
61
|
+
return (
|
|
62
|
+
pl.when(x < 15).then(None)
|
|
63
|
+
.when(x < 100).then(near(10))
|
|
64
|
+
.when(x < 1_000).then(near(50))
|
|
65
|
+
.when(x < 10_000).then(near(100))
|
|
66
|
+
.when(x < 100_000).then(near(500))
|
|
67
|
+
.when(x < 1_000_000).then(near(1_000))
|
|
68
|
+
.otherwise(f.round_sig_figs(4).round(0)) # .round(0) removes float noise like 999999999999.9
|
|
69
|
+
.cast(pl.Int64)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_columns(have, want, what: str) -> None:
|
|
74
|
+
missing = [c for c in want if c not in have]
|
|
75
|
+
if missing:
|
|
76
|
+
raise ValueError(f"{what}: missing columns {missing}. Columns present: {sorted(have)}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_annual_parquets(
|
|
80
|
+
input_folder,
|
|
81
|
+
filename_pattern: str,
|
|
82
|
+
years: Iterable[int],
|
|
83
|
+
column_map: Dict[str, str],
|
|
84
|
+
) -> pl.DataFrame:
|
|
85
|
+
"""Read one parquet per year, keep only the mapped columns, rename them, and concatenate.
|
|
86
|
+
|
|
87
|
+
column_map maps source column name -> canonical name (all eight canonical names needed).
|
|
88
|
+
Other columns in the files are never read into the result.
|
|
89
|
+
"""
|
|
90
|
+
years = list(years)
|
|
91
|
+
if "{year}" not in filename_pattern:
|
|
92
|
+
raise ValueError(f"filename_pattern must contain '{{year}}', got {filename_pattern!r}")
|
|
93
|
+
if sorted(column_map.values()) != sorted(CANONICAL_COLUMNS):
|
|
94
|
+
raise ValueError(
|
|
95
|
+
f"column_map must map exactly one source column to each of {CANONICAL_COLUMNS}; "
|
|
96
|
+
f"got targets {sorted(column_map.values())}"
|
|
97
|
+
)
|
|
98
|
+
if len(set(column_map)) != len(column_map):
|
|
99
|
+
raise ValueError("column_map has duplicate source columns")
|
|
100
|
+
input_folder = Path(input_folder)
|
|
101
|
+
if not input_folder.is_dir():
|
|
102
|
+
raise FileNotFoundError(f"Input folder does not exist: {input_folder}")
|
|
103
|
+
|
|
104
|
+
paths = {y: input_folder / filename_pattern.format(year=y) for y in years}
|
|
105
|
+
missing = [p.name for p in paths.values() if not p.is_file()]
|
|
106
|
+
if missing:
|
|
107
|
+
raise FileNotFoundError(f"{len(missing)} expected file(s) missing in {input_folder}: {missing}")
|
|
108
|
+
|
|
109
|
+
frames = []
|
|
110
|
+
first_schema = None
|
|
111
|
+
for year, path in paths.items():
|
|
112
|
+
lf = pl.scan_parquet(path)
|
|
113
|
+
_check_columns(lf.collect_schema().names(), list(column_map), path.name)
|
|
114
|
+
df = lf.select([pl.col(src).alias(dst) for src, dst in column_map.items()]).collect()
|
|
115
|
+
schema = dict(df.schema)
|
|
116
|
+
if first_schema is None:
|
|
117
|
+
first_schema = schema
|
|
118
|
+
elif schema != first_schema:
|
|
119
|
+
diff = {c: (str(first_schema[c]), str(schema[c])) for c in schema if schema[c] != first_schema[c]}
|
|
120
|
+
raise ValueError(f"{path.name}: column types differ from the first file (first, this): {diff}")
|
|
121
|
+
if not df.schema["year"].is_integer():
|
|
122
|
+
raise ValueError(f"{path.name}: year column must be an integer type, got {df.schema['year']}")
|
|
123
|
+
bad = df.filter(pl.col("year").is_null() | (pl.col("year") != year)).height
|
|
124
|
+
if bad:
|
|
125
|
+
raise ValueError(f"{path.name}: {bad} row(s) have a year other than {year}")
|
|
126
|
+
frames.append(df)
|
|
127
|
+
return pl.concat(frames, how="vertical")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _validate(df, years, ages, percentiles, unexpected_percentiles) -> pl.DataFrame:
|
|
131
|
+
_check_columns(df.columns, CANONICAL_COLUMNS, "data")
|
|
132
|
+
df = df.select(CANONICAL_COLUMNS)
|
|
133
|
+
|
|
134
|
+
# Required fields: no nulls anywhere.
|
|
135
|
+
nulls = {c: df[c].null_count() for c in CANONICAL_COLUMNS if df[c].null_count()}
|
|
136
|
+
if nulls:
|
|
137
|
+
raise ValueError(f"Missing values in required columns (counts): {nulls}")
|
|
138
|
+
|
|
139
|
+
# Numeric types: counts must be integers (or integer-valued floats) within MAX_COUNT.
|
|
140
|
+
for c in ["n", "deaths"]:
|
|
141
|
+
dtype = df.schema[c]
|
|
142
|
+
if dtype.is_float():
|
|
143
|
+
f = df[c]
|
|
144
|
+
if not f.is_finite().all() or (f != f.floor()).any():
|
|
145
|
+
raise ValueError(f"{c} must be whole numbers (finite, no fractions)")
|
|
146
|
+
elif not dtype.is_integer():
|
|
147
|
+
raise ValueError(f"{c} must be an integer type, got {dtype}")
|
|
148
|
+
if df[c].max() > MAX_COUNT or df[c].min() < 0:
|
|
149
|
+
raise ValueError(f"{c} outside 0..{MAX_COUNT}")
|
|
150
|
+
df = df.with_columns(pl.col(c).cast(pl.Int64))
|
|
151
|
+
for c in ["mortality_rate", "n_weighted"]:
|
|
152
|
+
dtype = df.schema[c]
|
|
153
|
+
if not (dtype.is_float() or dtype.is_integer()):
|
|
154
|
+
raise ValueError(f"{c} must be numeric, got {dtype}")
|
|
155
|
+
df = df.with_columns(pl.col(c).cast(pl.Float64))
|
|
156
|
+
if not df[c].is_finite().all():
|
|
157
|
+
raise ValueError(f"{c} has non-finite values")
|
|
158
|
+
if df["n"].min() <= 0:
|
|
159
|
+
raise ValueError("n must be positive")
|
|
160
|
+
if (df["deaths"] > df["n"]).any():
|
|
161
|
+
raise ValueError("deaths must not exceed n")
|
|
162
|
+
if df["mortality_rate"].min() < 0 or df["mortality_rate"].max() > 1:
|
|
163
|
+
raise ValueError("mortality_rate must be a probability between 0 and 1")
|
|
164
|
+
if df["n_weighted"].min() <= 0:
|
|
165
|
+
raise ValueError("n_weighted must be positive")
|
|
166
|
+
|
|
167
|
+
# Domains of the key columns.
|
|
168
|
+
if years is not None:
|
|
169
|
+
found = set(df["year"].unique().to_list())
|
|
170
|
+
if found != set(years):
|
|
171
|
+
raise ValueError(f"years differ from expected: missing {sorted(set(years) - found)}, unexpected {sorted(found - set(years))}")
|
|
172
|
+
if ages is not None:
|
|
173
|
+
bad = sorted(set(df["age"].unique().to_list()) - set(ages))
|
|
174
|
+
if bad:
|
|
175
|
+
raise ValueError(f"ages outside the expected range: {bad}")
|
|
176
|
+
if percentiles is not None:
|
|
177
|
+
bad = sorted(set(df["income_percentile"].unique().to_list()) - set(percentiles))
|
|
178
|
+
if bad:
|
|
179
|
+
if unexpected_percentiles == "drop":
|
|
180
|
+
print(f"Dropping {df.filter(pl.col('income_percentile').is_in(bad)).height} row(s) with income_percentile in {bad}")
|
|
181
|
+
df = df.filter(~pl.col("income_percentile").is_in(bad))
|
|
182
|
+
else:
|
|
183
|
+
raise ValueError(f"unexpected income_percentile values: {bad} (set unexpected_percentiles='drop' to drop them)")
|
|
184
|
+
|
|
185
|
+
# Keys must be unique.
|
|
186
|
+
dup = df.select(KEY_COLUMNS).is_duplicated().sum()
|
|
187
|
+
if dup:
|
|
188
|
+
raise ValueError(f"{dup} rows share a duplicate key ({', '.join(KEY_COLUMNS)})")
|
|
189
|
+
return df
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def prepare_disclosure(
|
|
193
|
+
df: pl.DataFrame,
|
|
194
|
+
rate_kind: Optional[str],
|
|
195
|
+
years: Optional[Iterable[int]] = None,
|
|
196
|
+
ages: Optional[Iterable[int]] = range(30, 96),
|
|
197
|
+
percentiles: Optional[Iterable] = None,
|
|
198
|
+
unexpected_percentiles: str = "error",
|
|
199
|
+
extra_suppress: Optional[pl.Expr] = None,
|
|
200
|
+
) -> Tuple[pl.DataFrame, pl.DataFrame]:
|
|
201
|
+
"""Validate the canonical table, then return (support, release).
|
|
202
|
+
|
|
203
|
+
rate_kind (required): "unweighted" releases rounded deaths / rounded n, rounded to four
|
|
204
|
+
significant figures. "weighted" releases the input rate rounded to four significant figures
|
|
205
|
+
(deaths are never rebuilt from a weighted rate). Input rates must be probabilities in 0..1.
|
|
206
|
+
|
|
207
|
+
percentiles: None accepts any income_percentile value (zero is kept, not dropped). Pass the
|
|
208
|
+
allowed values to check them; unexpected ones then raise, or are dropped with
|
|
209
|
+
unexpected_percentiles="drop". ages defaults to 30..95; None turns the age check off.
|
|
210
|
+
|
|
211
|
+
Suppression (chosen conservative policy, not a universal Census minimum): the whole row's
|
|
212
|
+
four numbers are set to null when n < 15, deaths < 15 or n - deaths < 15, which includes
|
|
213
|
+
zeros, or when extra_suppress (an optional polars Boolean expression over the canonical
|
|
214
|
+
columns, e.g. pl.col("age") > 90) is true. Keys stay. The release has no reason columns.
|
|
215
|
+
|
|
216
|
+
support keeps exact values, survivors (n - deaths) and the suppression flags. It is for the
|
|
217
|
+
support folder only, never for output.
|
|
218
|
+
"""
|
|
219
|
+
if rate_kind not in ("weighted", "unweighted"):
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"rate_kind must be 'weighted' or 'unweighted' (got {rate_kind!r}). It decides how the "
|
|
222
|
+
"released rate is built, so there is no default: choose it from how mortality_rate was made."
|
|
223
|
+
)
|
|
224
|
+
df = _validate(df, None if years is None else list(years), None if ages is None else list(ages),
|
|
225
|
+
None if percentiles is None else list(percentiles), unexpected_percentiles)
|
|
226
|
+
df = df.sort(KEY_COLUMNS)
|
|
227
|
+
|
|
228
|
+
flags = [
|
|
229
|
+
(pl.col("n") < MIN_COUNT).alias("suppress_n"),
|
|
230
|
+
(pl.col("deaths") < MIN_COUNT).alias("suppress_deaths"),
|
|
231
|
+
((pl.col("n") - pl.col("deaths")) < MIN_COUNT).alias("suppress_survivors"),
|
|
232
|
+
]
|
|
233
|
+
if extra_suppress is not None:
|
|
234
|
+
flags.append(extra_suppress.alias("suppress_extra"))
|
|
235
|
+
else:
|
|
236
|
+
flags.append(pl.lit(False).alias("suppress_extra"))
|
|
237
|
+
support = df.with_columns((pl.col("n") - pl.col("deaths")).alias("survivors")).with_columns(flags)
|
|
238
|
+
if support["suppress_extra"].dtype != pl.Boolean or support["suppress_extra"].null_count():
|
|
239
|
+
raise ValueError("extra_suppress must be a Boolean expression with no missing values")
|
|
240
|
+
support = support.with_columns(
|
|
241
|
+
pl.any_horizontal("suppress_n", "suppress_deaths", "suppress_survivors", "suppress_extra").alias("suppressed")
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
n_r, d_r = round_count("n"), round_count("deaths")
|
|
245
|
+
if rate_kind == "unweighted":
|
|
246
|
+
rate = (d_r.cast(pl.Float64) / n_r.cast(pl.Float64)).round_sig_figs(4)
|
|
247
|
+
else:
|
|
248
|
+
rate = pl.col("mortality_rate").round_sig_figs(4)
|
|
249
|
+
rounded = support.with_columns(
|
|
250
|
+
n_r.alias("n"), d_r.alias("deaths"), rate.alias("mortality_rate"),
|
|
251
|
+
pl.col("n_weighted").round_sig_figs(4),
|
|
252
|
+
)
|
|
253
|
+
release = rounded.select(
|
|
254
|
+
KEY_COLUMNS
|
|
255
|
+
+ [pl.when(pl.col("suppressed")).then(None).otherwise(pl.col(c)).alias(c) for c in ["n", "deaths", "mortality_rate", "n_weighted"]]
|
|
256
|
+
)
|
|
257
|
+
if release["mortality_rate"].max() is not None and release["mortality_rate"].max() > 1:
|
|
258
|
+
raise ValueError("a rounded rate exceeds 1; check the inputs")
|
|
259
|
+
support = support.select(
|
|
260
|
+
KEY_COLUMNS + ["n", "deaths", "survivors", "mortality_rate", "n_weighted"]
|
|
261
|
+
+ ["suppress_n", "suppress_deaths", "suppress_survivors", "suppress_extra", "suppressed"]
|
|
262
|
+
)
|
|
263
|
+
return support, release
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _write_xlsx(path: Path, df: pl.DataFrame, sheet: str) -> None:
|
|
267
|
+
"""Workbook with a CUI cover tab first (header text only), then the table."""
|
|
268
|
+
if df.height > EXCEL_MAX_DATA_ROWS:
|
|
269
|
+
raise ValueError(f"{path.name}: {df.height} rows do not fit in one Excel sheet")
|
|
270
|
+
wb = xlsxwriter.Workbook(str(path))
|
|
271
|
+
wb.add_worksheet("CUI").write(0, 0, CUI_TEXT)
|
|
272
|
+
df.write_excel(workbook=wb, worksheet=sheet, autofit=False)
|
|
273
|
+
wb.close()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def write_disclosure(
|
|
277
|
+
support: pl.DataFrame,
|
|
278
|
+
release: pl.DataFrame,
|
|
279
|
+
request_folder,
|
|
280
|
+
table_name: str = "Table_1",
|
|
281
|
+
release_csv_folder=None,
|
|
282
|
+
overwrite: bool = False,
|
|
283
|
+
) -> Dict[str, Path]:
|
|
284
|
+
"""Write the FSRDC request layout (Procedures Handbook v7.0.0, III.C) under request_folder.
|
|
285
|
+
|
|
286
|
+
request_folder/output/{table}_T13T26.xlsx the only file meant to be emailed
|
|
287
|
+
request_folder/support/{table}_support_CUI_SP-CENS_T13T26.csv exact combined data
|
|
288
|
+
request_folder/support/estimate_inventory_T13T26.xlsx counts of released estimates
|
|
289
|
+
request_folder/support/rounding/README.txt how to run the official rounder
|
|
290
|
+
|
|
291
|
+
Name request_folder with the request date, YYYYMMDD. The release is xlsx only, so the same
|
|
292
|
+
estimates are not submitted twice in two formats. A release CSV is written only if
|
|
293
|
+
release_csv_folder is given, and that folder must be outside request_folder.
|
|
294
|
+
Existing files are never replaced unless overwrite=True; nothing is deleted.
|
|
295
|
+
"""
|
|
296
|
+
root = Path(request_folder)
|
|
297
|
+
targets = {
|
|
298
|
+
"release_xlsx": root / "output" / f"{table_name}_T13T26.xlsx",
|
|
299
|
+
"support_csv": root / "support" / f"{table_name}_support_{CUI_TAG}.csv",
|
|
300
|
+
"inventory_xlsx": root / "support" / "estimate_inventory_T13T26.xlsx",
|
|
301
|
+
"rounding_readme": root / "support" / "rounding" / "README.txt",
|
|
302
|
+
}
|
|
303
|
+
if release_csv_folder is not None:
|
|
304
|
+
csv_dir = Path(release_csv_folder).resolve()
|
|
305
|
+
if csv_dir == root.resolve() or root.resolve() in csv_dir.parents:
|
|
306
|
+
raise ValueError("release_csv_folder must be outside the request folder, so CSV and xlsx are not both submitted")
|
|
307
|
+
targets["release_csv"] = csv_dir / f"{table_name}_{CUI_TAG}.csv"
|
|
308
|
+
existing = [str(p) for p in targets.values() if p.exists()]
|
|
309
|
+
if existing and not overwrite:
|
|
310
|
+
raise FileExistsError(f"Refusing to overwrite existing files (pass overwrite=True): {existing}")
|
|
311
|
+
|
|
312
|
+
for p in targets.values():
|
|
313
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
_write_xlsx(targets["release_xlsx"], release, table_name)
|
|
315
|
+
support.write_csv(targets["support_csv"])
|
|
316
|
+
if "release_csv" in targets:
|
|
317
|
+
release.write_csv(targets["release_csv"])
|
|
318
|
+
|
|
319
|
+
n_cells = int(sum(release[c].is_not_null().sum() for c in NUMERIC_COLUMNS))
|
|
320
|
+
inventory = pl.DataFrame({
|
|
321
|
+
"table": [table_name],
|
|
322
|
+
"rows_total": [release.height],
|
|
323
|
+
"rows_suppressed": [int(support["suppressed"].sum())],
|
|
324
|
+
"rows_released": [int(release.height - support["suppressed"].sum())],
|
|
325
|
+
"numeric_columns_per_row": [len(NUMERIC_COLUMNS)],
|
|
326
|
+
"estimates_released_nonnull": [n_cells],
|
|
327
|
+
"prior_releases_of_this_sample": ["UNKNOWN - fill in from the project's output tracking sheet"],
|
|
328
|
+
})
|
|
329
|
+
_write_xlsx(targets["inventory_xlsx"], inventory, "inventory")
|
|
330
|
+
targets["rounding_readme"].write_text(ROUNDING_README)
|
|
331
|
+
return targets
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# --- Import necessary packages ---
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
from health import prepare_disclosure, print_unique, read_annual_parquets, write_disclosure
|
|
8
|
+
|
|
9
|
+
###########################################################
|
|
10
|
+
# Settings: edit this section, nothing below needs to change
|
|
11
|
+
###########################################################
|
|
12
|
+
|
|
13
|
+
# Folder holding the 24 annual parquet files.
|
|
14
|
+
INPUT_FOLDER = Path("PATH/TO/INPUT_FOLDER")
|
|
15
|
+
|
|
16
|
+
# File name of each year's file; {year} is replaced by 2001, 2002, ..., 2024.
|
|
17
|
+
FILENAME_PATTERN = "mortality_{year}.parquet"
|
|
18
|
+
YEARS = list(range(2001, 2025))
|
|
19
|
+
|
|
20
|
+
# Column names in your files (left) and the canonical names this script uses (right).
|
|
21
|
+
# Only these eight columns are read. Replace the placeholder names with your real ones.
|
|
22
|
+
COLUMN_MAP = {
|
|
23
|
+
"year_placeholder": "year",
|
|
24
|
+
"sex_placeholder": "sex",
|
|
25
|
+
"income_percentile_placeholder": "income_percentile",
|
|
26
|
+
"age_placeholder": "age",
|
|
27
|
+
"mortality_rate_placeholder": "mortality_rate",
|
|
28
|
+
"deaths_placeholder": "deaths",
|
|
29
|
+
"n_placeholder": "n",
|
|
30
|
+
"n_weighted_placeholder": "n_weighted",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# How mortality_rate was built. You must choose: "weighted" or "unweighted".
|
|
34
|
+
# "unweighted": the released rate is rounded deaths / rounded n.
|
|
35
|
+
# "weighted": the released rate is your rate rounded to four significant figures.
|
|
36
|
+
RATE_KIND = None
|
|
37
|
+
|
|
38
|
+
# Ages and income percentiles allowed. PERCENTILES = None accepts any value (including 0).
|
|
39
|
+
AGES = range(30, 96)
|
|
40
|
+
PERCENTILES = None
|
|
41
|
+
|
|
42
|
+
# Optional extra rule: also mask the rows where this Boolean expression is true.
|
|
43
|
+
EXTRA_SUPPRESS = None # e.g. pl.col("age") > 90
|
|
44
|
+
|
|
45
|
+
# Request date as YYYYMMDD (the FSRDC request folder name) and where the request folders live.
|
|
46
|
+
REQUEST_DATE = None
|
|
47
|
+
OUTPUT_ROOT = Path("PATH/TO/disclosure")
|
|
48
|
+
|
|
49
|
+
# Name of the table. The file to submit will be output/Table_1_T13T26.xlsx.
|
|
50
|
+
TABLE_NAME = "Table_1"
|
|
51
|
+
|
|
52
|
+
# Optional: a folder OUTSIDE the request folder for a rounded CSV copy. None writes no CSV.
|
|
53
|
+
RELEASE_CSV_FOLDER = None
|
|
54
|
+
|
|
55
|
+
# False refuses to replace files that already exist.
|
|
56
|
+
OVERWRITE = False
|
|
57
|
+
|
|
58
|
+
if REQUEST_DATE is None or not re.fullmatch(r"\d{8}", str(REQUEST_DATE)):
|
|
59
|
+
raise ValueError("Set REQUEST_DATE to the request date as YYYYMMDD, e.g. '20261101'.")
|
|
60
|
+
|
|
61
|
+
###########################################################
|
|
62
|
+
# Read, check, round and mask
|
|
63
|
+
###########################################################
|
|
64
|
+
|
|
65
|
+
# Read the 24 files; this fails if a file, a column or a year is wrong.
|
|
66
|
+
data = read_annual_parquets(INPUT_FOLDER, FILENAME_PATTERN, YEARS, COLUMN_MAP)
|
|
67
|
+
print_unique(data, "year")
|
|
68
|
+
|
|
69
|
+
# Validate (unique keys, ranges, no missing values), then build the exact and the release tables.
|
|
70
|
+
support, release = prepare_disclosure(
|
|
71
|
+
data,
|
|
72
|
+
rate_kind=RATE_KIND,
|
|
73
|
+
years=YEARS,
|
|
74
|
+
ages=AGES,
|
|
75
|
+
percentiles=PERCENTILES,
|
|
76
|
+
extra_suppress=EXTRA_SUPPRESS,
|
|
77
|
+
)
|
|
78
|
+
print(f"{support['suppressed'].sum()} of {support.height} rows masked")
|
|
79
|
+
|
|
80
|
+
###########################################################
|
|
81
|
+
# Write the request folder
|
|
82
|
+
###########################################################
|
|
83
|
+
|
|
84
|
+
# output/ has the xlsx to submit; support/ has the exact data, inventory and rounding notes.
|
|
85
|
+
paths = write_disclosure(
|
|
86
|
+
support,
|
|
87
|
+
release,
|
|
88
|
+
OUTPUT_ROOT / str(REQUEST_DATE),
|
|
89
|
+
table_name=TABLE_NAME,
|
|
90
|
+
release_csv_folder=RELEASE_CSV_FOLDER,
|
|
91
|
+
overwrite=OVERWRITE,
|
|
92
|
+
)
|
|
93
|
+
for name, path in paths.items():
|
|
94
|
+
print(f"{name}: {path}")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Copied from ltc_code.helpers.print_unique (LTC code package, by William Ratnavale),
|
|
7
|
+
# with the same signature and behavior, so health does not need to import ltc_code.
|
|
8
|
+
# Define a function that prints the unique values of a specified column for a lazy or data frame and its counts
|
|
9
|
+
def print_unique(
|
|
10
|
+
df: Union[pl.DataFrame, pl.LazyFrame],
|
|
11
|
+
col: str,
|
|
12
|
+
) -> pl.DataFrame:
|
|
13
|
+
out = (
|
|
14
|
+
df
|
|
15
|
+
.group_by(col)
|
|
16
|
+
.len()
|
|
17
|
+
.sort("len", descending=True)
|
|
18
|
+
)
|
|
19
|
+
# execute if lazyframe
|
|
20
|
+
if isinstance(out, pl.LazyFrame):
|
|
21
|
+
out = out.collect()
|
|
22
|
+
# if # unique vals > max table height then print them all in a loop
|
|
23
|
+
n = out.height
|
|
24
|
+
if n <= 15:
|
|
25
|
+
print(out)
|
|
26
|
+
else:
|
|
27
|
+
print(f"{n} unique values in '{col}':")
|
|
28
|
+
for row in out.iter_rows(named=True):
|
|
29
|
+
print(f"{row[col]}: {row['len']}")
|
|
30
|
+
return out
|