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.
@@ -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