palfit 0.1.0__py3-none-any.whl

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.
palfit/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Fit a physical activity level (PAL) to an observed weight trajectory with Hall's adult model."""
2
+
3
+ from palfit._fit import FitResult, fit_pal
4
+ from palfit._model import Trajectory, simulate
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = ["FitResult", "Trajectory", "__version__", "fit_pal", "simulate"]
palfit/_fit.py ADDED
@@ -0,0 +1,128 @@
1
+ """Invert the Hall model: observed weights -> the PAL that reproduces them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Optional, Sequence
8
+
9
+ from palfit._model import (
10
+ Trajectory,
11
+ simulate,
12
+ validate_intake,
13
+ validate_person,
14
+ )
15
+
16
+ _INVPHI = (math.sqrt(5) - 1) / 2
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class FitResult:
21
+ pal: float
22
+ maintenance_kcal: float # mean model expenditure over the last ``tail_days``
23
+ sse_kg2: float
24
+ n_obs: int
25
+ hit_lower: bool # optimum pinned at the lower bound: the fit is not trustworthy
26
+ hit_upper: bool
27
+ trajectory: Trajectory
28
+
29
+
30
+ def _observed(observed_kg: Sequence[Optional[float]], n: int) -> list[Optional[float]]:
31
+ if len(observed_kg) != n:
32
+ raise ValueError("intake_kcal and observed_kg must be the same length")
33
+ out: list[Optional[float]] = []
34
+ for i, v in enumerate(observed_kg):
35
+ if v is None:
36
+ out.append(None)
37
+ continue
38
+ if isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) or v <= 0:
39
+ raise ValueError(f"observed_kg[{i}] must be None or a positive finite number, got {v!r}")
40
+ out.append(float(v))
41
+ return out
42
+
43
+
44
+ def fit_pal(
45
+ *,
46
+ height_m: float,
47
+ age_years: float,
48
+ sex: str,
49
+ start_weight_kg: float,
50
+ intake_kcal: Sequence[float],
51
+ observed_kg: Sequence[Optional[float]],
52
+ fat0_kg: Optional[float] = None,
53
+ carb_fraction: float = 0.5,
54
+ baseline_carb_fraction: float = 0.5,
55
+ lo: float = 1.1,
56
+ hi: float = 2.5,
57
+ tol: float = 1e-4,
58
+ tail_days: int = 7,
59
+ ) -> FitResult:
60
+ """Golden-section search for the PAL whose simulated weight best matches ``observed_kg``.
61
+
62
+ ``observed_kg[i]`` is ``None`` on days without a reading. ``intake_kcal`` must
63
+ have no gaps. At least two observations are required; with none the error
64
+ surface is flat and a search would silently return a bound.
65
+
66
+ ``hit_lower`` / ``hit_upper`` are set when the optimum lies within ``10 * tol``
67
+ of a bound; treat such a result as "the data cannot be explained", not as an
68
+ estimate.
69
+ """
70
+ if not 0 < tol < 1:
71
+ raise ValueError("tol must be in (0, 1)")
72
+ if not 0 < lo < hi:
73
+ raise ValueError("need 0 < lo < hi")
74
+ if not isinstance(tail_days, int) or tail_days < 1:
75
+ raise ValueError("tail_days must be a positive integer")
76
+ validate_person(height_m, age_years, sex, start_weight_kg, fat0_kg)
77
+ validate_intake(intake_kcal)
78
+ obs = _observed(observed_kg, len(intake_kcal))
79
+ n_obs = sum(o is not None for o in obs)
80
+ if n_obs < 2:
81
+ raise ValueError(f"need at least 2 observed weights, got {n_obs}")
82
+
83
+ def run(pal: float) -> Trajectory:
84
+ return simulate(
85
+ height_m=height_m,
86
+ age_years=age_years,
87
+ sex=sex,
88
+ start_weight_kg=start_weight_kg,
89
+ pal=pal,
90
+ intake_kcal=intake_kcal,
91
+ fat0_kg=fat0_kg,
92
+ carb_fraction=carb_fraction,
93
+ baseline_carb_fraction=baseline_carb_fraction,
94
+ )
95
+
96
+ def sse(traj: Trajectory) -> float:
97
+ total = sum((s - o) ** 2 for s, o in zip(traj.weight_kg, obs) if o is not None)
98
+ return total if math.isfinite(total) else math.inf
99
+
100
+ def cost(pal: float) -> float:
101
+ return sse(run(pal))
102
+
103
+ a, b = lo, hi
104
+ c = b - _INVPHI * (b - a)
105
+ d = a + _INVPHI * (b - a)
106
+ fc, fd = cost(c), cost(d)
107
+ while abs(b - a) > tol:
108
+ if fc < fd:
109
+ b, d, fd = d, c, fc
110
+ c = b - _INVPHI * (b - a)
111
+ fc = cost(c)
112
+ else:
113
+ a, c, fc = c, d, fd
114
+ d = a + _INVPHI * (b - a)
115
+ fd = cost(d)
116
+ pal = (a + b) / 2
117
+ traj = run(pal)
118
+ tail = traj.expenditure_kcal[-tail_days:]
119
+ eps = 10 * tol
120
+ return FitResult(
121
+ pal=pal,
122
+ maintenance_kcal=sum(tail) / len(tail),
123
+ sse_kg2=sse(traj),
124
+ n_obs=n_obs,
125
+ hit_lower=pal <= lo + eps,
126
+ hit_upper=pal >= hi - eps,
127
+ trajectory=traj,
128
+ )
palfit/_model.py ADDED
@@ -0,0 +1,234 @@
1
+ """Hall adult body-weight ODE, forward simulation.
2
+
3
+ Equations and constants are transcribed from INSP-RH/bw ``src/adult_weight.cpp``
4
+ (MIT, see NOTICE). Intake forcing follows upstream exactly: upstream reads the
5
+ intake change as ``EIchange[floor(t / dt)]``, so within the RK4 step from day
6
+ i-1 to day i the stages k1-k3 (t = i-1, i-1/2) see day i-1's value and only k4
7
+ (t = i) sees day i's.
8
+
9
+ Deliberate deviation from upstream: when the baseline-day carbohydrate intake
10
+ is zero, the carb/sodium coupling term of the ECF equation is dropped instead
11
+ of dividing by zero (upstream and ``hallmodel`` return NaN there).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from dataclasses import dataclass
18
+ from typing import Optional, Sequence
19
+
20
+ # Units: energy kcal, mass kg, sodium mg. Verbatim from Adult::getParameters().
21
+ ROG = 4206.501 # kcal/kg glycogen
22
+ NA_CONST = 3220.0
23
+ ZETA_NA = 3000.0
24
+ ZETA_CI = 4000.0
25
+ ROF = 9440.727 # kcal/kg fat
26
+ ROL = 1816.444 # kcal/kg lean tissue
27
+ GAMMA_F = 3.107075
28
+ GAMMA_L = 21.98853
29
+ ETA_F = 179.2543
30
+ ETA_L = 229.4455
31
+ BETA_TEF = 0.1
32
+ BETA_AT = 0.14
33
+ TAU_AT = 14.0 # days
34
+ C_CONST = 10.4 * (ROL / ROF)
35
+ ALFA1 = -(1.0 + ETA_L / ROL) * C_CONST
36
+ ALFA2 = -(1.0 + ETA_F / ROF)
37
+ G_BASE_KG = 0.5
38
+ GLYCOGEN_WATER = 3.7 # kg of glycogen + bound water per kg glycogen
39
+
40
+ # Mifflin-St Jeor, as in Adult::getRMR.
41
+ _RMR_BW, _RMR_HT, _RMR_AGE, _RMR_M, _RMR_F = 9.99, 625.0, 4.92, 5.0, 161.0
42
+
43
+ SEXES = ("male", "female")
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Trajectory:
48
+ """Day-indexed model output; index 0 is the initial condition."""
49
+
50
+ pal: float
51
+ rmr_kcal: float
52
+ baseline_expenditure_kcal: float # RMR * PAL, the steady-state expenditure
53
+ weight_kg: tuple[float, ...]
54
+ fat_kg: tuple[float, ...]
55
+ lean_kg: tuple[float, ...]
56
+ glycogen_kg: tuple[float, ...] # glycogen only; its bound water is in weight_kg
57
+ ecf_kg: tuple[float, ...]
58
+ adaptive_thermogenesis_kcal: tuple[float, ...]
59
+ expenditure_kcal: tuple[float, ...]
60
+
61
+
62
+ def _finite(name: str, x: float) -> float:
63
+ if isinstance(x, bool) or not isinstance(x, (int, float)) or not math.isfinite(x):
64
+ raise ValueError(f"{name} must be a finite number, got {x!r}")
65
+ return float(x)
66
+
67
+
68
+ def validate_person(
69
+ height_m: float,
70
+ age_years: float,
71
+ sex: str,
72
+ start_weight_kg: float,
73
+ fat0_kg: Optional[float],
74
+ ) -> None:
75
+ if sex not in SEXES:
76
+ raise ValueError(f"sex must be one of {SEXES}, got {sex!r}")
77
+ if not 0.5 <= _finite("height_m", height_m) <= 2.5:
78
+ raise ValueError(f"height_m must be in metres (0.5-2.5), got {height_m}")
79
+ if _finite("age_years", age_years) < 18:
80
+ raise ValueError("this is the adult model: age_years must be >= 18")
81
+ if _finite("start_weight_kg", start_weight_kg) <= 0:
82
+ raise ValueError("start_weight_kg must be positive")
83
+ if fat0_kg is not None and not 0 < _finite("fat0_kg", fat0_kg) < start_weight_kg:
84
+ raise ValueError("fat0_kg must be between 0 and start_weight_kg")
85
+
86
+
87
+ def validate_intake(intake_kcal: Sequence[float]) -> None:
88
+ if len(intake_kcal) < 2:
89
+ raise ValueError("need at least 2 days of intake to integrate")
90
+ for i, v in enumerate(intake_kcal):
91
+ if v is None:
92
+ raise ValueError(f"intake_kcal[{i}] is None: fill intake gaps before calling")
93
+ if _finite(f"intake_kcal[{i}]", v) < 0:
94
+ raise ValueError(f"intake_kcal[{i}] is negative")
95
+
96
+
97
+ def rmr_kcal(weight_kg: float, height_m: float, age_years: float, sex: str) -> float:
98
+ base = _RMR_BW * weight_kg + _RMR_HT * height_m -_RMR_AGE * age_years
99
+ return base + _RMR_M if sex == "male" else base - _RMR_F
100
+
101
+
102
+ def _ecf_init(bw: float, ht: float, age: float, sex: str) -> float:
103
+ """Silva's equation, Adult::getECFinit."""
104
+ if sex == "male":
105
+ return 0.025 * age + 9.57 * ht + 0.191 * bw - 12.4
106
+ return -4.0 + 5.98 * ht + 0.167 * bw
107
+
108
+
109
+ def _baseline_fat_kg(bw: float, ht: float, age: float, sex: str) -> float:
110
+ """Adult::getBaselineMass; used only when no measured fat mass is given."""
111
+ bmi = bw / ht**2
112
+ if sex == "male":
113
+ pct = 0.14 * age + 37.31 * math.log(bmi) - 103.94
114
+ else:
115
+ pct = 0.14 * age + 39.96 * math.log(bmi) - 102.01
116
+ return bw * pct / 100.0
117
+
118
+
119
+ def simulate(
120
+ *,
121
+ height_m: float,
122
+ age_years: float,
123
+ sex: str,
124
+ start_weight_kg: float,
125
+ pal: float,
126
+ intake_kcal: Sequence[float],
127
+ fat0_kg: Optional[float] = None,
128
+ carb_fraction: float = 0.5,
129
+ baseline_carb_fraction: float = 0.5,
130
+ ) -> Trajectory:
131
+ """Run the model forward, one step per entry of ``intake_kcal``.
132
+
133
+ ``intake_kcal[0]`` is the baseline (energy-balance) intake. The model starts
134
+ on day 0 at ``start_weight_kg`` with zero adaptive thermogenesis and
135
+ expenditure equal to ``RMR * pal``.
136
+ """
137
+ validate_person(height_m, age_years, sex, start_weight_kg, fat0_kg)
138
+ validate_intake(intake_kcal)
139
+ if not 0.0 < _finite("pal", pal) < 5.0:
140
+ raise ValueError(f"pal must be in (0, 5), got {pal}")
141
+ for nm, v in (("carb_fraction", carb_fraction), ("baseline_carb_fraction", baseline_carb_fraction)):
142
+ if not 0.0 <= _finite(nm, v) <= 1.0:
143
+ raise ValueError(f"{nm} must be in [0, 1]")
144
+
145
+ bw0, ht, age = float(start_weight_kg), float(height_m), float(age_years)
146
+ n = len(intake_kcal)
147
+ ei0 = float(intake_kcal[0])
148
+
149
+ rmr = rmr_kcal(bw0, ht, age, sex)
150
+ ecf_init = _ecf_init(bw0, ht, age, sex)
151
+ fat_base = float(fat0_kg) if fat0_kg is not None else _baseline_fat_kg(bw0, ht, age, sex)
152
+ lean_base = bw0 - (ecf_init + fat_base + GLYCOGEN_WATER * G_BASE_KG)
153
+ if lean_base <= 0 or fat_base <= 0:
154
+ raise ValueError("inputs imply non-positive fat or lean mass; check units and fat0_kg")
155
+ delta = ((1.0 - BETA_TEF) * pal - 1.0) * rmr / bw0
156
+ k_const = rmr * pal - GAMMA_L * lean_base - GAMMA_F * fat_base - delta * bw0
157
+ cib = baseline_carb_fraction * ei0
158
+ kg_const = cib / (G_BASE_KG**2)
159
+ delta_ei = [float(v) - ei0 for v in intake_kcal]
160
+
161
+ def fat_of(lean: float) -> float:
162
+ return fat_base * math.exp(ROL * (lean - lean_base) / (ROF * C_CONST))
163
+
164
+ def d_g(g: float, dei: float) -> float:
165
+ return (carb_fraction * (ei0 + dei) - kg_const * g * g) / ROG
166
+
167
+ def d_at(at: float, dei: float) -> float:
168
+ return (BETA_AT * dei - at) / TAU_AT
169
+
170
+ def d_ecf(ecf: float, dei: float) -> float:
171
+ ci = carb_fraction * (ei0 + dei)
172
+ ci_term = ZETA_CI * (1.0 - ci / cib) if cib else 0.0
173
+ return (-ZETA_NA * (ecf - ecf_init) - ci_term) / NA_CONST
174
+
175
+ def d_l(lean: float, g: float, at: float, ecf: float, dei: float) -> float:
176
+ fat = fat_of(lean)
177
+ weight = lean + fat + ecf + GLYCOGEN_WATER * g
178
+ r3 = k_const + delta * weight + BETA_TEF * dei + at - (ei0 + dei) + ROG * d_g(g, dei)
179
+ r = (r3 + GAMMA_L * lean + GAMMA_F * fat) / (ALFA1 + ALFA2 * fat)
180
+ return r * (C_CONST / ROL)
181
+
182
+ def expenditure(lean: float, fat: float, ecf: float, g: float, at: float, dei: float) -> float:
183
+ weight = lean + fat + ecf + GLYCOGEN_WATER * g
184
+ return k_const + GAMMA_L * lean + GAMMA_F * fat + delta * weight + BETA_TEF * dei + at
185
+
186
+ def rk4(f, y: float, dei_a: float, dei_b: float) -> float:
187
+ # k1-k3 evaluated at t = i-1 (+ half step): previous day's forcing; k4 at t = i.
188
+ k1 = f(y, dei_a)
189
+ k2 = f(y + 0.5 * k1, dei_a)
190
+ k3 = f(y + 0.5 * k2, dei_a)
191
+ k4 = f(y + k3, dei_b)
192
+ return y + (k1 + 2 * k2 + 2 * k3 + k4) / 6.0
193
+
194
+ at = [0.0] * n
195
+ ecf = [ecf_init] * n
196
+ g = [G_BASE_KG] * n
197
+ lean = [lean_base] * n
198
+ fat = [fat_base] * n
199
+ bw = [bw0] * n
200
+ ee = [0.0] * n
201
+ ee[0] = expenditure(lean[0], fat[0], ecf[0], g[0], at[0], delta_ei[0])
202
+
203
+ for i in range(1, n):
204
+ da, db = delta_ei[i - 1], delta_ei[i]
205
+ at[i] = rk4(d_at, at[i - 1], da, db)
206
+ ecf[i] = rk4(d_ecf, ecf[i - 1], da, db)
207
+ g[i] = rk4(d_g, g[i - 1], da, db)
208
+
209
+ g_avg = 0.5 * (g[i] + g[i - 1])
210
+ at_avg = 0.5 * (at[i] + at[i - 1])
211
+ ecf_avg = 0.5 * (ecf[i] + ecf[i - 1])
212
+ y = lean[i - 1]
213
+ k1 = d_l(y, g[i - 1], at[i - 1], ecf[i - 1], da)
214
+ k2 = d_l(y + 0.5 * k1, g_avg, at_avg, ecf_avg, da)
215
+ k3 = d_l(y + 0.5 * k2, g_avg, at_avg, ecf_avg, da)
216
+ k4 = d_l(y + k3, g[i], at[i], ecf[i], db)
217
+ lean[i] = y + (k1 + 2 * k2 + 2 * k3 + k4) / 6.0
218
+
219
+ fat[i] = fat_of(lean[i])
220
+ bw[i] = fat[i] + lean[i] + ecf[i] + GLYCOGEN_WATER * g[i]
221
+ ee[i] = expenditure(lean[i], fat[i], ecf[i], g[i], at[i], db)
222
+
223
+ return Trajectory(
224
+ pal=float(pal),
225
+ rmr_kcal=rmr,
226
+ baseline_expenditure_kcal=rmr * pal,
227
+ weight_kg=tuple(bw),
228
+ fat_kg=tuple(fat),
229
+ lean_kg=tuple(lean),
230
+ glycogen_kg=tuple(g),
231
+ ecf_kg=tuple(ecf),
232
+ adaptive_thermogenesis_kcal=tuple(at),
233
+ expenditure_kcal=tuple(ee),
234
+ )
palfit/py.typed ADDED
File without changes
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.5
2
+ Name: palfit
3
+ Version: 0.1.0
4
+ Summary: Fit a physical activity level (PAL) and maintenance calories to a weight log with Hall's adult body-weight model. Pure Python, no dependencies.
5
+ Project-URL: Homepage, https://github.com/112345brian/palfit
6
+ Project-URL: Repository, https://github.com/112345brian/palfit
7
+ Project-URL: Issues, https://github.com/112345brian/palfit/issues
8
+ Author: 112345brian
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ License-File: NOTICE
12
+ Keywords: Hall model,PAL,TDEE,body weight,energy balance,maintenance calories
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Provides-Extra: parity
24
+ Requires-Dist: hallmodel; extra == 'parity'
25
+ Requires-Dist: numpy; extra == 'parity'
26
+ Requires-Dist: pytest>=8; extra == 'parity'
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest>=8; extra == 'test'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # palfit
32
+
33
+ Fit a **physical activity level (PAL)**, and from it a maintenance-calorie estimate, to a daily intake log and a gappy body-weight log, using Kevin Hall's adult dynamic body-weight model. Pure Python, no runtime dependencies.
34
+
35
+ The forward model is a port of the R package [INSP-RH/bw](https://github.com/INSP-RH/bw) (MIT). The compiled PyPI package `hallmodel` offers the same forward model; **neither it nor `bw` fits PAL to data**. That inversion is what this package adds. See `NOTICE`.
36
+
37
+ Source and issues: https://github.com/112345brian/palfit
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install palfit
43
+ ```
44
+
45
+ ## Use
46
+
47
+ ```python
48
+ import palfit
49
+
50
+ result = palfit.fit_pal(
51
+ height_m=1.78, age_years=32, sex="male",
52
+ start_weight_kg=85.0,
53
+ intake_kcal=intake, # one value per day, no gaps; intake[0] is the baseline day
54
+ observed_kg=weights, # same length; None on days without a reading
55
+ )
56
+
57
+ if result.hit_lower or result.hit_upper:
58
+ print("PAL pinned at a bound: the weights cannot be explained by this model")
59
+ print(result.pal, result.maintenance_kcal)
60
+ result.trajectory.fat_kg, .lean_kg, .glycogen_kg, .ecf_kg, .weight_kg, .expenditure_kcal
61
+ ```
62
+
63
+ * `fit_pal(*, height_m, age_years, sex, start_weight_kg, intake_kcal, observed_kg, fat0_kg=None, carb_fraction=0.5, baseline_carb_fraction=0.5, lo=1.1, hi=2.5, tol=1e-4, tail_days=7) -> FitResult`
64
+ * `simulate(*, height_m, age_years, sex, start_weight_kg, pal, intake_kcal, fat0_kg=None, carb_fraction=0.5, baseline_carb_fraction=0.5) -> Trajectory`
65
+ * `FitResult`: `pal`, `maintenance_kcal`, `sse_kg2`, `n_obs`, `hit_lower`, `hit_upper`, `trajectory`. Frozen dataclasses of tuples.
66
+ * Raises `ValueError` for: unknown sex, age under 18, height not in metres, non-finite or negative values, `None` in intake, length mismatch, fewer than 2 observed weights, `fat0_kg` outside (0, weight).
67
+ * No file, database, network or global state; inputs are never mutated.
68
+
69
+ ## What PAL means here
70
+
71
+ PAL = total daily energy expenditure divided by resting metabolic rate (RMR, Mifflin-St Jeor from the **start weight**). `fit_pal` searches PAL in `[lo, hi]` (golden section) to minimise the squared difference between simulated and observed weights.
72
+
73
+ * `maintenance_kcal` is the model's expenditure averaged over the **last `tail_days` of the simulated period**, which includes the model's adaptive-thermogenesis and thermic-effect terms for that period. It is not simply `RMR * PAL` (that value is `trajectory.baseline_expenditure_kcal`, the steady-state figure at the start).
74
+ * The model does **not** assume day-0 intake equals expenditure. Day 0 is the model's "energy balance" reference for deviations (`intake - intake[0]`), and PAL is free. Pick a typical day as `intake[0]`, not a fast day or a feast.
75
+ * Glycogen is reported as glycogen only; its bound water (3.7 kg per kg glycogen) is inside `weight_kg`.
76
+
77
+ ## Limitations (read these)
78
+
79
+ * **Garbage in, garbage out on intake.** Logged intake that is 10% low or high moves the estimated maintenance by about -240 / +240 kcal in the test case below. The fit cannot tell a bad log from a different metabolism.
80
+ * **The start weight is treated as exact.** A start weight off by 0.5 kg shifted the fit by about 0.03 PAL (about 50 kcal) in the test case. Use a smoothed or averaged start value.
81
+ * **Short windows are noisy.** In the synthetic study, 28 days with 0.4 kg noise gave an RMSE of about 0.027 PAL (roughly 50 kcal).
82
+ * **Flat error surface is not detected.** With almost no intake variation or a very short window, many PALs fit about equally well and the optimum is poorly determined. Only hitting a bound is flagged.
83
+ * Adult model only; daily steps only; sodium input not supported; no uncertainty interval.
84
+ * Not medical advice. The model is a published population-level approximation; this package has been tested against that model, **not against measured individuals**.
85
+
86
+ ## What is verified, and how
87
+
88
+ Everything below was run on macOS arm64 with `hallmodel==2026.10.21.16`. "Not verified" items are listed explicitly.
89
+
90
+ | Claim | Evidence |
91
+ |---|---|
92
+ | `simulate` equals `hallmodel.adult_weight` on 7 scenarios (steady, carb surge, cut, female bulk, high PAL, long refeed, noisy week) | Test tolerances 1e-9 kg and 1e-6 kcal on all series; measured worst case on the carb-surge scenario: 0.0 kg weight, 4.5e-13 kcal expenditure |
93
+ | Intake forcing matches upstream C++ (`EIchange[floor(t/dt)]`: RK4 stages k1-k3 use the previous day's value, k4 the current day's) | Read from `adult_weight.cpp`; a port using the current day in all stages differed from `hallmodel` by up to 0.43 kg and 12 kcal before the change |
94
+ | PAL recovery on model-generated data (truth 1.72, 90 days, 0.15 kg noise, a third of days missing, 30 seeds) | bias 0.0002, RMSE 0.0015, worst error 0.003 PAL |
95
+ | Same, 28 days / 56 days, 0.15 kg noise | RMSE 0.0102 / 0.0036 PAL |
96
+ | Energy balance closes to within the model's tissue-efficiency losses | stored/net ratio 0.96-0.97 over 30-365 days (a test guards 0.93-1.0) |
97
+ | Zero baseline intake gives finite output | tested; this deliberately differs from upstream/`hallmodel`, which return NaN |
98
+
99
+ **Not verified:** agreement with the R package itself (R was not run; the claim rests on the C++ source and `hallmodel`); correctness of `hallmodel`'s own parity claim (its upstream "contract" branch is not publicly visible); behaviour on Python 3.11 and on non-macOS platforms; real-world accuracy against measured expenditure (doubly labelled water or similar); PAL uncertainty estimates; unimodality of the error surface over `[1.1, 2.5]` beyond the cases above.
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ pip install -e '.[test]' # unit tests, no network or data needed
105
+ pip install -e '.[parity]' # adds numpy + hallmodel for the dev-only parity tests
106
+ pytest
107
+ ```
108
+
109
+ ## Before release
110
+
111
+ Run `python tools/check_release.py`; it fails while any placeholder marker remains in `LICENSE`, `NOTICE`, `README.md` or `pyproject.toml`, or if `authors` is missing. Re-check that the distribution name is unclaimed on PyPI immediately before uploading.
112
+
113
+ ## Licence
114
+
115
+ MIT. Model code derived from INSP-RH/bw (MIT, Instituto Nacional de Salud Pública); see `LICENSE` and `NOTICE`.
@@ -0,0 +1,9 @@
1
+ palfit/__init__.py,sha256=Wz4yNR1G3dvJv8MuTWugKayt4CwMc1cqsoa_PXroBH0,290
2
+ palfit/_fit.py,sha256=GNQtXP74GmMdIPo448qpnuQvNUIo-xqSa9R503S-PgM,4052
3
+ palfit/_model.py,sha256=-PNQKCzgSdbKWrRMQ6_2xPY_Q6IvFV3f6hwaMNjd__g,8845
4
+ palfit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ palfit-0.1.0.dist-info/METADATA,sha256=Jx2UMlXUr2wcXkONDSWHHuqtqqaao213xGYHDDnOKzw,7447
6
+ palfit-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ palfit-0.1.0.dist-info/licenses/LICENSE,sha256=lpxTl9nZ175fydExAGEOEJrF3GbUIGLvIqsbJywg0Fw,1186
8
+ palfit-0.1.0.dist-info/licenses/NOTICE,sha256=pB2wxAHN3dDtGH2cveB_F4f2LAhH_PKJ7crNrpSxd0g,2468
9
+ palfit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018 Instituto Nacional de Salud Pública (INSP-RH/bw, from which the model code is ported; see NOTICE)
4
+ Copyright (c) 2026 112345brian
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,44 @@
1
+ NOTICE / ATTRIBUTION
2
+
3
+ This package is a derivative work. Its model code (src/palfit/_model.py) is a
4
+ Python port of the adult model in the R package INSP-RH/bw
5
+ (https://github.com/INSP-RH/bw), file src/adult_weight.cpp. Equations, constants,
6
+ and the order of operations are transcribed from that file; they were not re-derived.
7
+
8
+ Upstream copyright and licence
9
+ Copyright (c) 2018 Instituto Nacional de Salud Pública (México)
10
+ Licensed under the MIT licence (DESCRIPTION: "MIT + file LICENSE"; the full MIT
11
+ text is in the header of the upstream C++ files). The MIT text is reproduced in
12
+ this package's LICENSE file.
13
+ Upstream authors named in the source headers: Dalia Camacho-García-Formentí,
14
+ Rodrigo Zepeda-Tello.
15
+
16
+ What is original here (not in bw)
17
+ - The inversion (fit_pal): golden-section search over PAL against observed weights.
18
+ - Bound-hit flags, input validation, the all-missing-weights guard.
19
+ - The guard that drops the carbohydrate/sodium term of the ECF equation when the
20
+ baseline-day carbohydrate intake is zero (upstream returns NaN there).
21
+ - Tests, including parity tests against the `hallmodel` package (dev-only).
22
+
23
+ What is not ported
24
+ - The child model, sodium-driven ECF changes (the sodium input is fixed at zero
25
+ change), multi-individual matrices, intake interpolation (energy_build), and
26
+ non-daily time steps.
27
+
28
+ Scientific sources of the model (as cited in the upstream source header)
29
+ Chow, C.C. and Hall, K.D. (2008). The Dynamics of Human Body Weight Change.
30
+ PLoS Comput Biol 4(3): e1000045.
31
+ Hall, K.D. (2010). Predicting Metabolic Adaptation, Body Weight Change, and Energy
32
+ Intake in Humans. Am J Physiol Endocrinol Metab 298(3): E449-E466.
33
+ Hall, K.D. and Jordan, P.N. (2008). Modeling Weight-Loss Maintenance to Help
34
+ Prevent Body Weight Regain. Am J Clin Nutr 88(6): 1495-1503.
35
+ Hall, K.D., Sacks, G., Chandramohan, D., Chow, C.C., Wang, Y.C., Gortmaker, S.L.,
36
+ Swinburn, B.A. (2011). Quantification of the Effect of Energy Imbalance on
37
+ Bodyweight. The Lancet 378(9793): 826-837.
38
+ Mifflin, M.D., St Jeor, S.T., Hill, L.A., Scott, B.J., Daugherty, S.A., Koh, Y.O.
39
+ (1990). A New Predictive Equation for Resting Energy Expenditure in Healthy
40
+ Individuals. Am J Clin Nutr 51(2): 241-247.
41
+
42
+ Related software (used only as a test-time reference, never vendored)
43
+ `hallmodel` on PyPI (compiled C++ binding of the same upstream model, MIT).
44
+ https://github.com/ChocoTonic/hallmodel-py