ahl-npm 1.0.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.
ahl_npm-1.0.0/.envrc ADDED
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+ # MAKE EDITS TO .ENV INSTEAD
3
+ [ -f .env ] && source .env
@@ -0,0 +1,38 @@
1
+ name: Publish to PyPI
2
+
3
+ # Publishes the package to PyPI when a GitHub release is published, building it from the release's tag, which must
4
+ # match the version (`v` + `__version__` in ahl_npm/__init__.py). Uses PyPI trusted publishing from the `pypi`
5
+ # environment, so no API token is needed. Failed runs can be re-run from the Actions tab, but PyPI never accepts the
6
+ # same version twice.
7
+ on:
8
+ release:
9
+ types: [published]
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ environment:
15
+ name: pypi
16
+ url: https://pypi.org/project/ahl-npm/
17
+ permissions:
18
+ id-token: write # trusted publishing
19
+ contents: read
20
+ steps:
21
+ - uses: actions/checkout@v5
22
+
23
+ - name: Check the tag is the version
24
+ run: |
25
+ VERSION=$(sed -nE 's/^__version__ = "([^"]+)".*/\1/p' ahl_npm/__init__.py)
26
+ if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then
27
+ echo "::error::The tag $GITHUB_REF_NAME is not v + __version__ ($VERSION)."
28
+ exit 1
29
+ fi
30
+
31
+ - name: Install uv
32
+ uses: astral-sh/setup-uv@v6
33
+
34
+ - name: Build the package
35
+ run: uv build
36
+
37
+ - name: Publish to PyPI
38
+ run: uv publish --trusted-publishing always
@@ -0,0 +1,35 @@
1
+ name: Tests
2
+
3
+ # Runs the tests under each supported Python version, and with the oldest dependency versions allowed (the tox envs
4
+ # in pyproject.toml). Dependencies are resolved from PyPI each time, so the weekly run catches any new numpy or polars
5
+ # release that breaks ahl-npm.
6
+ on:
7
+ pull_request:
8
+ push:
9
+ branches: [dev, main]
10
+ schedule:
11
+ - cron: "0 6 * * 1"
12
+ workflow_dispatch:
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ tox:
19
+ name: tox -e ${{ matrix.env }}
20
+ runs-on: ubuntu-latest
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ env: [py310, py311, py312, py313, py314, py315, lowest]
25
+ steps:
26
+ - uses: actions/checkout@v5
27
+
28
+ - name: Install uv
29
+ uses: astral-sh/setup-uv@v6
30
+
31
+ - name: Install tox
32
+ run: uv tool install tox --with tox-uv
33
+
34
+ - name: Run the tests
35
+ run: tox run -e ${{ matrix.env }}
@@ -0,0 +1,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+
5
+ # Builds
6
+ build/
7
+ dist/
8
+ *.egg-info/
9
+
10
+ # Environments and tools
11
+ .venv/
12
+ .tox/
13
+ .pytest_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ htmlcov/
17
+ .ipynb_checkpoints/
18
+
19
+ # Local settings
20
+ .env
21
+
22
+ # Editors and OS
23
+ .idea/
24
+ .vscode/
25
+ .DS_Store
@@ -0,0 +1,32 @@
1
+ repos:
2
+ - repo: https://gitlab.com/vojko.pribudic.foss/pre-commit-update
3
+ rev: v0.10.0
4
+ hooks:
5
+ - id: pre-commit-update
6
+ - repo: https://github.com/pre-commit/pre-commit-hooks
7
+ rev: v6.0.0
8
+ hooks:
9
+ - id: check-added-large-files
10
+ - id: check-merge-conflict
11
+ - id: end-of-file-fixer
12
+ - id: trailing-whitespace
13
+ - id: check-toml
14
+ - id: check-yaml
15
+ - id: no-commit-to-branch
16
+ args: ["-b", dev, "-b", main]
17
+ pass_filenames: false
18
+ - repo: https://github.com/nestauk/pre-commit-hooks
19
+ rev: v1.2.0
20
+ hooks:
21
+ - id: nbstripout-preserve-timestamp
22
+ - id: jupytext-enforce-pairing
23
+ - id: jupytext-smart-sync
24
+ - repo: https://github.com/astral-sh/ruff-pre-commit
25
+ rev: v0.17.3
26
+ hooks:
27
+ - id: ruff-format
28
+ - id: ruff-check
29
+ - repo: https://github.com/prettier/pre-commit
30
+ rev: v2.1.2
31
+ hooks:
32
+ - id: prettier
ahl_npm-1.0.0/LICENSE ADDED
@@ -0,0 +1,10 @@
1
+
2
+ The MIT License (MIT)
3
+ =====================
4
+ Copyright (c) 2026, Nesta
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
ahl_npm-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.5
2
+ Name: ahl-npm
3
+ Version: 1.0.0
4
+ Summary: Functions and configuration for calculating Nutrient Profiling Model (NPM) scores
5
+ Project-URL: repository, https://github.com/nestauk/ahl_npm
6
+ Project-URL: issues, https://github.com/nestauk/ahl_npm/issues
7
+ Author-email: Harry Wilde <harry.wilde@nesta.org.uk>
8
+ Maintainer-email: Harry Wilde <harry.wilde@nesta.org.uk>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: HFSS,NPM,numpy,nutrient profiling model,nutrition,polars
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: numpy>=1.26
22
+ Requires-Dist: polars>=1.25
23
+ Requires-Dist: pyyaml>=6.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # AHL NPM Scoring
27
+
28
+ Two definitions of the UK's Nutrient Profiling Model, [NPM 2004/05](https://www.gov.uk/government/publications/the-nutrient-profiling-model) and [NPM 2018](https://www.gov.uk/government/publications/nutrient-profiling-model-2018/nutrient-profiling-model-2018-technical-guidance), scored on `numpy` arrays or `polars` frames.
29
+
30
+ ## Installing
31
+
32
+ The package is installable from [PyPI](https://pypi.org/project/ahl-npm/) on Python 3.10+ with `pip`, `uv`, etc.:
33
+
34
+ ```bash
35
+ uv add ahl-npm
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ import ahl_npm as npm
42
+
43
+ # numpy: numbers or arrays per 100g, which broadcast together (e.g. one panel against multiple possible free sugars values)
44
+ scored = npm.score(
45
+ scheme=2018, energy_kj=1500, satfat=4.5, free_sugars=[2.0, 6.5], salt=1.25, protein=10, fibre=3, fvns_pct=0
46
+ )
47
+ scored["npm"] # array([11., 15.]), with each component's points, the A and C points and whether protein counts
48
+
49
+ # polars: `columns` maps each input to a column or an expression; without it, the frame's columns named as inputs
50
+ scored = npm.score_frame(
51
+ frame=df,
52
+ scheme=2004,
53
+ columns={
54
+ "energy_kcal": "kcal_per_100g",
55
+ "satfat": "satfat_per_100g",
56
+ "sugars": "sugar_per_100g",
57
+ "sodium": "sodium_per_100g",
58
+ "protein": "protein_per_100g",
59
+ "fibre": "fibre_per_100g",
60
+ "fvn_pct": pl.col("fvn_share") * 100,
61
+ },
62
+ ) # adds energy_points_2004, ..., a_points_2004, c_points_2004, protein_counted_2004, npm_2004, cnpm_2004
63
+ ```
64
+
65
+ `outputs` picks which columns to add and, when passed as a mapping, renames them (between `prefix` and `suffix`), e.g. `outputs={"npm": "npm_score", "cnpm": "cnpm_score"}` only adds `npm_score_2004` and `cnpm_score_2004`. Inputs a scheme doesn't use are ignored, so the same mapping can be used for both schemes.
66
+
67
+ `score_expr` returns the same scoring as a struct expression, and `band` and `band_expr` give the points for a single component, e.g. `npm.band(2018, "free_sugars", values)`.
68
+
69
+ ### Inputs
70
+
71
+ All inputs are densities (g/100g) as sold. Where a component can be scored from more than one input, each row uses the first one available: the quantity the scheme scores, then a converted stand-in, then the component's points if given.
72
+
73
+ | Input | Unit | Scored by |
74
+ | -------------------------- | ---- | ------------------------------------------------------------------------------------- |
75
+ | `energy_kj`, `energy_kcal` | kJ | both, using kJ if given, else kcal x 4.184 |
76
+ | `satfat` | g | both |
77
+ | `sugars` | g | NPM 2004/05 (total sugars) |
78
+ | `free_sugars` | g | NPM 2018 |
79
+ | `sodium`, `salt` | g | NPM 2004/05 scores sodium (else salt / 2.5), NPM 2018 scores salt (else sodium x 2.5) |
80
+ | `protein` | g | both |
81
+ | `fibre` | g | both (AOAC method) |
82
+ | `fvn_pct` | % | NPM 2004/05 (fruit, vegetables and nuts) |
83
+ | `fvns_pct` | % | NPM 2018 (fruit, vegetables, nuts and seeds) |
84
+ | `<component>_points` | | both, for points already scored, as an alternative to the densities above |
85
+
86
+ Points inputs are named the same as the outputs, so a scoring's points can be passed straight back in, and are checked against the points the component can score under the scheme. NPM 2018 includes seeds, so it only uses `fvns_pct` or `fvns_points`: if you only have the NPM 2004/05 share, pass it as `fvns_pct` too. NPM 2018 also needs free sugars. Scoring total sugars as free sugars isn't supported, as it overestimates NPM 2018 scores, but can be done by passing total sugars as `free_sugars`.
87
+
88
+ Salt is sodium x 2.5, as UK labelling law defines it ([Regulation (EU) No 1169/2011, Annex I](https://www.legislation.gov.uk/eur/2011/1169/annex/I), as retained in UK law).
89
+
90
+ ### Outputs
91
+
92
+ Each component's points (`<component>_points`), `a_points`, `c_points`, `protein_counted`, `npm` and `cnpm` (70 - 2 x `npm`). The numpy functions return floats with NaN where unknown (or integers with `strict=True`, which raises on missing inputs), and the polars functions return integers (a boolean for `protein_counted`) with nulls where unknown. Protein stops counting from 11 A points, unless fruit, vegetables and nuts score 5 points.
93
+
94
+ ### Checks and warnings
95
+
96
+ The most likely mistake is an input in the wrong units, so each frame scoring logs (at INFO, to the `ahl_npm` logger) which column or expression each component reads, in what units, and with any conversion. `describe_inputs` gives the same description for any inputs:
97
+
98
+ ```text
99
+ NPM 2004 reads, per 100 g:
100
+ energy: energy_kcal <- "kcal_per_100g", kcal x 4.184 to kJ
101
+ sodium: sodium <- "sodium_per_100g", g; else salt <- "salt_per_100g", g / 2.5 to sodium
102
+ fvn: fvn_pct <- [(col("fvn_share")) * (dyn int: 100)], %
103
+ ...
104
+ ```
105
+
106
+ - `ImpossibleInputWarning` warns of implausible density values, i.e. below 0, or above 100g, 100% or the energy of pure fat (3,700 kJ or 900 kcal), which usually means an input is in the wrong units (e.g. sodium in mg read as g).
107
+ - `MissingInputWarning` summarises missing inputs (NaN, or null in polars). A missing input scores NaN points rather than the maximum, so any score it feeds into is missing too.
108
+ - `warn=False` turns both warnings off. A frame only warns once, for the first batch of rows with problems.
109
+
110
+ ### Other functions
111
+
112
+ - `to_cnpm` and `from_cnpm` convert between NPM and cNPM (70 - 2 x the score, s.t. higher is healthier) for numbers, arrays, and polars expressions or series. `clip=True` clips cNPM to between 0 and 100, and `change=True` converts changes in scores (without the offset).
113
+ - `less_healthy(scheme, npm, drink)` gives whether each product is less healthy, i.e. a food scoring 4 or more, or a drink scoring 1 or more. `drink` must be booleans with none missing (in polars, a null gives a null). Whether a product is in scope of HFSS legislation is a separate question.
114
+ - `score_range(scheme, low, high, **fixed)` gives the lowest and highest scores for inputs anywhere between their `low` and `high` values. The score never decreases as an A input increases or a C input decreases, so only two scorings are needed, however many inputs are uncertain.
115
+
116
+ ## Development Setup
117
+
118
+ This project uses [`uv`](https://docs.astral.sh/uv/) and `direnv`. To set up a development environment, run:
119
+
120
+ ```bash
121
+ direnv allow
122
+ uv sync
123
+ uv run pre-commit install --install-hooks
124
+ uv run pytest
125
+ ```
126
+
127
+ `uv run tox` runs the tests under each Python version from 3.10 to 3.15, and (`lowest`) with the oldest numpy, polars and PyYAML versions allowed. CI runs these on every pull request and push to `dev` or `main`, and weekly.
@@ -0,0 +1,102 @@
1
+ # AHL NPM Scoring
2
+
3
+ Two definitions of the UK's Nutrient Profiling Model, [NPM 2004/05](https://www.gov.uk/government/publications/the-nutrient-profiling-model) and [NPM 2018](https://www.gov.uk/government/publications/nutrient-profiling-model-2018/nutrient-profiling-model-2018-technical-guidance), scored on `numpy` arrays or `polars` frames.
4
+
5
+ ## Installing
6
+
7
+ The package is installable from [PyPI](https://pypi.org/project/ahl-npm/) on Python 3.10+ with `pip`, `uv`, etc.:
8
+
9
+ ```bash
10
+ uv add ahl-npm
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ import ahl_npm as npm
17
+
18
+ # numpy: numbers or arrays per 100g, which broadcast together (e.g. one panel against multiple possible free sugars values)
19
+ scored = npm.score(
20
+ scheme=2018, energy_kj=1500, satfat=4.5, free_sugars=[2.0, 6.5], salt=1.25, protein=10, fibre=3, fvns_pct=0
21
+ )
22
+ scored["npm"] # array([11., 15.]), with each component's points, the A and C points and whether protein counts
23
+
24
+ # polars: `columns` maps each input to a column or an expression; without it, the frame's columns named as inputs
25
+ scored = npm.score_frame(
26
+ frame=df,
27
+ scheme=2004,
28
+ columns={
29
+ "energy_kcal": "kcal_per_100g",
30
+ "satfat": "satfat_per_100g",
31
+ "sugars": "sugar_per_100g",
32
+ "sodium": "sodium_per_100g",
33
+ "protein": "protein_per_100g",
34
+ "fibre": "fibre_per_100g",
35
+ "fvn_pct": pl.col("fvn_share") * 100,
36
+ },
37
+ ) # adds energy_points_2004, ..., a_points_2004, c_points_2004, protein_counted_2004, npm_2004, cnpm_2004
38
+ ```
39
+
40
+ `outputs` picks which columns to add and, when passed as a mapping, renames them (between `prefix` and `suffix`), e.g. `outputs={"npm": "npm_score", "cnpm": "cnpm_score"}` only adds `npm_score_2004` and `cnpm_score_2004`. Inputs a scheme doesn't use are ignored, so the same mapping can be used for both schemes.
41
+
42
+ `score_expr` returns the same scoring as a struct expression, and `band` and `band_expr` give the points for a single component, e.g. `npm.band(2018, "free_sugars", values)`.
43
+
44
+ ### Inputs
45
+
46
+ All inputs are densities (g/100g) as sold. Where a component can be scored from more than one input, each row uses the first one available: the quantity the scheme scores, then a converted stand-in, then the component's points if given.
47
+
48
+ | Input | Unit | Scored by |
49
+ | -------------------------- | ---- | ------------------------------------------------------------------------------------- |
50
+ | `energy_kj`, `energy_kcal` | kJ | both, using kJ if given, else kcal x 4.184 |
51
+ | `satfat` | g | both |
52
+ | `sugars` | g | NPM 2004/05 (total sugars) |
53
+ | `free_sugars` | g | NPM 2018 |
54
+ | `sodium`, `salt` | g | NPM 2004/05 scores sodium (else salt / 2.5), NPM 2018 scores salt (else sodium x 2.5) |
55
+ | `protein` | g | both |
56
+ | `fibre` | g | both (AOAC method) |
57
+ | `fvn_pct` | % | NPM 2004/05 (fruit, vegetables and nuts) |
58
+ | `fvns_pct` | % | NPM 2018 (fruit, vegetables, nuts and seeds) |
59
+ | `<component>_points` | | both, for points already scored, as an alternative to the densities above |
60
+
61
+ Points inputs are named the same as the outputs, so a scoring's points can be passed straight back in, and are checked against the points the component can score under the scheme. NPM 2018 includes seeds, so it only uses `fvns_pct` or `fvns_points`: if you only have the NPM 2004/05 share, pass it as `fvns_pct` too. NPM 2018 also needs free sugars. Scoring total sugars as free sugars isn't supported, as it overestimates NPM 2018 scores, but can be done by passing total sugars as `free_sugars`.
62
+
63
+ Salt is sodium x 2.5, as UK labelling law defines it ([Regulation (EU) No 1169/2011, Annex I](https://www.legislation.gov.uk/eur/2011/1169/annex/I), as retained in UK law).
64
+
65
+ ### Outputs
66
+
67
+ Each component's points (`<component>_points`), `a_points`, `c_points`, `protein_counted`, `npm` and `cnpm` (70 - 2 x `npm`). The numpy functions return floats with NaN where unknown (or integers with `strict=True`, which raises on missing inputs), and the polars functions return integers (a boolean for `protein_counted`) with nulls where unknown. Protein stops counting from 11 A points, unless fruit, vegetables and nuts score 5 points.
68
+
69
+ ### Checks and warnings
70
+
71
+ The most likely mistake is an input in the wrong units, so each frame scoring logs (at INFO, to the `ahl_npm` logger) which column or expression each component reads, in what units, and with any conversion. `describe_inputs` gives the same description for any inputs:
72
+
73
+ ```text
74
+ NPM 2004 reads, per 100 g:
75
+ energy: energy_kcal <- "kcal_per_100g", kcal x 4.184 to kJ
76
+ sodium: sodium <- "sodium_per_100g", g; else salt <- "salt_per_100g", g / 2.5 to sodium
77
+ fvn: fvn_pct <- [(col("fvn_share")) * (dyn int: 100)], %
78
+ ...
79
+ ```
80
+
81
+ - `ImpossibleInputWarning` warns of implausible density values, i.e. below 0, or above 100g, 100% or the energy of pure fat (3,700 kJ or 900 kcal), which usually means an input is in the wrong units (e.g. sodium in mg read as g).
82
+ - `MissingInputWarning` summarises missing inputs (NaN, or null in polars). A missing input scores NaN points rather than the maximum, so any score it feeds into is missing too.
83
+ - `warn=False` turns both warnings off. A frame only warns once, for the first batch of rows with problems.
84
+
85
+ ### Other functions
86
+
87
+ - `to_cnpm` and `from_cnpm` convert between NPM and cNPM (70 - 2 x the score, s.t. higher is healthier) for numbers, arrays, and polars expressions or series. `clip=True` clips cNPM to between 0 and 100, and `change=True` converts changes in scores (without the offset).
88
+ - `less_healthy(scheme, npm, drink)` gives whether each product is less healthy, i.e. a food scoring 4 or more, or a drink scoring 1 or more. `drink` must be booleans with none missing (in polars, a null gives a null). Whether a product is in scope of HFSS legislation is a separate question.
89
+ - `score_range(scheme, low, high, **fixed)` gives the lowest and highest scores for inputs anywhere between their `low` and `high` values. The score never decreases as an A input increases or a C input decreases, so only two scorings are needed, however many inputs are uncertain.
90
+
91
+ ## Development Setup
92
+
93
+ This project uses [`uv`](https://docs.astral.sh/uv/) and `direnv`. To set up a development environment, run:
94
+
95
+ ```bash
96
+ direnv allow
97
+ uv sync
98
+ uv run pre-commit install --install-hooks
99
+ uv run pytest
100
+ ```
101
+
102
+ `uv run tox` runs the tests under each Python version from 3.10 to 3.15, and (`lowest`) with the oldest numpy, polars and PyYAML versions allowed. CI runs these on every pull request and push to `dev` or `main`, and weekly.
@@ -0,0 +1,43 @@
1
+ """ahl_npm: the UK's Nutrient Profiling Model (NPM 2004/05 and NPM 2018), scored on numpy arrays or polars frames."""
2
+
3
+ __version__ = "1.0.0"
4
+
5
+ from ahl_npm.constants import KJ_PER_KCAL, SALT_PER_SODIUM
6
+ from ahl_npm.frames import band_expr, score_expr, score_frame
7
+ from ahl_npm.schemes import COMPONENTS, SCHEMES, Component, Scheme, get_scheme, load_scheme
8
+ from ahl_npm.scoring import (
9
+ INPUTS,
10
+ ImpossibleInputWarning,
11
+ MissingInputWarning,
12
+ band,
13
+ describe_inputs,
14
+ output_names,
15
+ score,
16
+ score_range,
17
+ )
18
+ from ahl_npm.transforms import from_cnpm, less_healthy, to_cnpm
19
+
20
+ __all__ = [
21
+ "COMPONENTS",
22
+ "INPUTS",
23
+ "ImpossibleInputWarning",
24
+ "KJ_PER_KCAL",
25
+ "SALT_PER_SODIUM",
26
+ "SCHEMES",
27
+ "Component",
28
+ "MissingInputWarning",
29
+ "Scheme",
30
+ "band",
31
+ "band_expr",
32
+ "describe_inputs",
33
+ "from_cnpm",
34
+ "get_scheme",
35
+ "less_healthy",
36
+ "load_scheme",
37
+ "output_names",
38
+ "score",
39
+ "score_expr",
40
+ "score_frame",
41
+ "score_range",
42
+ "to_cnpm",
43
+ ]
@@ -0,0 +1,19 @@
1
+ """Constants used in NPM scoring that are not part of a scheme's thresholds."""
2
+
3
+ # Kilojoules per kilocalorie, used to convert energy given in kcal, as both schemes score energy in kJ.
4
+ KJ_PER_KCAL: float = 4.184
5
+
6
+ # Grams of salt per gram of sodium, as UK labelling law defines salt (salt = sodium x 2.5, Regulation (EU) No 1169/2011,
7
+ # Annex I, as retained in UK law).
8
+ SALT_PER_SODIUM: float = 2.5
9
+
10
+ # cNPM is 70 - 2 x the NPM score, s.t. higher is healthier. It runs from -10 to 100 under NPM 2004/05, and up to 110
11
+ # under NPM 2018. CNPM_BOUNDS are the limits used when clipping.
12
+ CNPM_OFFSET: float = 70
13
+ CNPM_SCALE: float = -2
14
+ CNPM_BOUNDS: tuple[float, float] = (0, 100)
15
+
16
+ # A value scores a threshold's point only above the threshold by more than BAND_ATOL + BAND_RTOL x the threshold,
17
+ # this prevents floating point error from causing a value to be scored at a different threshold.
18
+ BAND_ATOL: float = 1e-4
19
+ BAND_RTOL: float = 1e-5
@@ -0,0 +1,185 @@
1
+ """NPM scores on polars frames and expressions.
2
+
3
+ The scoring reuses the numpy implementation `ahl_npm.scoring.score`, s.t. scoring a dataframe is consistent with
4
+ scoring an array of its rows, and scoring an expression is consistent with scoring a series of its values.
5
+ """
6
+
7
+ import logging
8
+ import threading
9
+ import warnings
10
+ from collections import Counter
11
+ from collections.abc import Mapping, Sequence
12
+ from typing import TypeVar
13
+
14
+ import polars as pl
15
+
16
+ from ahl_npm.schemes import Scheme, get_scheme
17
+ from ahl_npm.scoring import INPUTS, _check_inputs, _inputs_used, _score, band, describe_inputs, output_names
18
+
19
+ logger = logging.getLogger("ahl_npm")
20
+
21
+ FrameT = TypeVar("FrameT", pl.DataFrame, pl.LazyFrame)
22
+
23
+ # Temporary column holding a frame's scoring struct before its fields are split into columns.
24
+ _SCORING = "__ahl_npm_scoring"
25
+
26
+ # Recently logged input descriptions, s.t. each is only logged once however often a scoring is rebuilt (e.g. in a
27
+ # sweep or a dashboard). Only the latest `_LOGGED_MOST` are kept, so memory use stays bounded when inputs vary.
28
+ _LOGGED: dict[str, None] = {}
29
+ _LOGGED_MOST = 256
30
+
31
+
32
+ def _log_once(description: str) -> None:
33
+ if description in _LOGGED:
34
+ return
35
+ _LOGGED[description] = None
36
+ if len(_LOGGED) > _LOGGED_MOST:
37
+ del _LOGGED[next(iter(_LOGGED))]
38
+ logger.info(description)
39
+
40
+
41
+ def _as_expr(column: str | pl.Expr) -> pl.Expr:
42
+ return pl.col(column) if isinstance(column, str) else column
43
+
44
+
45
+ def _output_dtypes(scheme: str | int | Scheme) -> dict[str, pl.DataType]:
46
+ """Return the polars dtype of each of `output_names`: integers for points and scores, and a boolean flag."""
47
+ return {name: pl.Boolean if name == "protein_counted" else pl.Int64 for name in output_names(scheme)}
48
+
49
+
50
+ def score_expr(scheme: str | int | Scheme, columns: Mapping[str, str | pl.Expr], *, warn: bool = True) -> pl.Expr:
51
+ """Return an expression for each row's NPM scoring, as a struct with a field per `output_names`, null if unknown.
52
+
53
+ Inputs the scheme doesn't use are ignored, so the same mapping can be used for both schemes. The expression only
54
+ warns once, with the counts from the first batch of rows with missing or implausible values, which is the whole
55
+ frame unless it is scored in batches (e.g. by a streaming collect).
56
+
57
+ Args:
58
+ scheme (str | int | Scheme): 2004, 2018 or a `Scheme`.
59
+ columns (Mapping[str, str | pl.Expr]): A column name or expression for each input, by `score`'s names.
60
+ warn (bool): Whether to warn, with a summary, of missing inputs and implausible density values.
61
+
62
+ Returns:
63
+ pl.Expr: The scoring as a struct, e.g. to `.struct.unnest()` or `.struct.field()`.
64
+
65
+ Raises:
66
+ TypeError: If an input is not one of `INPUTS`.
67
+ ValueError: If a component has none of the inputs it can be scored from.
68
+ """
69
+ scheme = get_scheme(scheme)
70
+ _check_inputs(scheme, columns)
71
+ used = _inputs_used(scheme)
72
+ names = [name for name in INPUTS if name in columns and name in used]
73
+ _log_once(describe_inputs(scheme, {name: columns[name] for name in names}))
74
+ dtypes = _output_dtypes(scheme)
75
+ warned = not warn
76
+ claim = threading.Lock()
77
+
78
+ def score_batch(rows: pl.Series) -> pl.Series:
79
+ """Score a batch of rows (a struct of the inputs) with numpy."""
80
+ nonlocal warned
81
+ inputs = rows.struct.unnest()
82
+ scored, problems = _score(scheme, {name: inputs[name].to_numpy() for name in names}, check=not warned)
83
+ if problems:
84
+ with claim: # A streaming collect scores batches concurrently, so only the first batch with problems warns
85
+ first, warned = not warned, True
86
+ if first:
87
+ for category, message in problems:
88
+ warnings.warn(message, category, stacklevel=2)
89
+ fields = [pl.Series(name, values, nan_to_null=True).cast(dtypes[name]) for name, values in scored.items()]
90
+ return pl.DataFrame(fields).to_struct(rows.name)
91
+
92
+ struct = pl.struct([_as_expr(columns[name]).cast(pl.Float64).alias(name) for name in names])
93
+ return struct.map_batches(score_batch, return_dtype=pl.Struct(dtypes), is_elementwise=True)
94
+
95
+
96
+ def score_frame(
97
+ frame: FrameT,
98
+ scheme: str | int | Scheme,
99
+ columns: Mapping[str, str | pl.Expr] | None = None,
100
+ *,
101
+ prefix: str = "",
102
+ suffix: str | None = None,
103
+ outputs: Sequence[str] | Mapping[str, str] | None = None,
104
+ warn: bool = True,
105
+ ) -> FrameT:
106
+ """Return `frame` with each row's NPM scoring under `scheme` added as columns.
107
+
108
+ The columns added are `outputs` (by default all of `output_names`, i.e. each component's points, `a_points`,
109
+ `c_points`, `protein_counted`, `npm` and `cnpm`), each named between `prefix` and `suffix`, e.g.
110
+ `energy_points_2018`, ..., `cnpm_2018` by default.
111
+
112
+ Args:
113
+ frame (FrameT): The rows to score, as a `pl.DataFrame` or `pl.LazyFrame`.
114
+ scheme (str | int | Scheme): 2004, 2018 or a `Scheme`.
115
+ columns (Mapping[str, str | pl.Expr] | None): A column name or expression for each input, by `score`'s names.
116
+ If None, the frame's columns named as inputs are used.
117
+ prefix (str): Prefix for each new column's name.
118
+ suffix (str | None): Suffix for each new column's name, `_<scheme>` if None.
119
+ outputs (Sequence[str] | Mapping[str, str] | None): Which of `output_names` to add: a single name, a sequence
120
+ of names, or a mapping to new names (placed between `prefix` and `suffix`). None adds all of them.
121
+ warn (bool): Whether to warn, with a summary, of missing inputs and of implausible density values.
122
+
123
+ Returns:
124
+ FrameT: `frame` with the new columns, of the same type.
125
+
126
+ Raises:
127
+ ValueError: If an output is not one of `output_names`, two outputs would have the same name, a new column
128
+ would replace an input column, or the frame already has the temporary column the scoring uses.
129
+ """
130
+ scheme = get_scheme(scheme)
131
+ suffix = f"_{scheme.name}" if suffix is None else suffix
132
+ present = frame.collect_schema().names()
133
+ if _SCORING in present:
134
+ raise ValueError(f"The frame already has a column called {_SCORING}, which the scoring uses internally")
135
+ if columns is None:
136
+ columns = {name: name for name in INPUTS if name in present}
137
+ if isinstance(outputs, str):
138
+ outputs = (outputs,)
139
+ known = output_names(scheme)
140
+ chosen = (
141
+ dict.fromkeys(known)
142
+ if outputs is None
143
+ else dict(outputs)
144
+ if isinstance(outputs, Mapping)
145
+ else dict.fromkeys(outputs)
146
+ )
147
+ unknown = sorted(set(chosen) - set(known))
148
+ if unknown:
149
+ raise ValueError(f"No NPM {scheme.name} outputs {unknown}: the outputs are {known}")
150
+ named = {output: f"{prefix}{name or output}{suffix}" for output, name in chosen.items()}
151
+ shared = sorted(name for name, count in Counter(named.values()).items() if count > 1)
152
+ if shared:
153
+ raise ValueError(f"Outputs would share the names {shared}")
154
+
155
+ used = {column for value in columns.values() for column in _as_expr(value).meta.root_names()}
156
+ clashes = sorted(used & set(named.values()))
157
+ if clashes:
158
+ raise ValueError(f"The scoring would replace input columns {clashes}: give a prefix, suffix or other outputs")
159
+
160
+ scoring = pl.col(_SCORING)
161
+ fields = [scoring.struct.field(output).alias(name) for output, name in named.items()]
162
+ return (
163
+ frame.with_columns(score_expr(scheme, columns, warn=warn).alias(_SCORING)).with_columns(fields).drop(_SCORING)
164
+ )
165
+
166
+
167
+ def band_expr(scheme: str | int | Scheme, component: str, values: str | pl.Expr) -> pl.Expr:
168
+ """Return an expression for the points one component gives each value per 100g, null where a value is missing.
169
+
170
+ Args:
171
+ scheme (str | int | Scheme): 2004, 2018 or a `Scheme`.
172
+ component (str): The component, e.g. "free_sugars", with values in its main input's units (e.g. kJ for energy).
173
+ values (str | pl.Expr): The values per 100g, as a column name or an expression.
174
+
175
+ Returns:
176
+ pl.Expr: The points as integers.
177
+ """
178
+ scheme = get_scheme(scheme)
179
+ scheme.component(component) # Raise here, rather than at collect, if the scheme doesn't have the component
180
+
181
+ def band_batch(batch: pl.Series) -> pl.Series:
182
+ """Band a batch of values with numpy."""
183
+ return pl.Series(batch.name, band(scheme, component, batch.to_numpy()), nan_to_null=True).cast(pl.Int64)
184
+
185
+ return _as_expr(values).cast(pl.Float64).map_batches(band_batch, return_dtype=pl.Int64, is_elementwise=True)