Skip to content

Commit d65109e

Browse files
authored
Merge pull request #1747 from PolicyEngine/codex/support-local-h5-datasets
Support local dataset file paths
2 parents 7ba4c12 + 9f87f10 commit d65109e

3 files changed

Lines changed: 169 additions & 25 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Allow UK simulations to load local dataset file paths, including materialized H5 files passed by upstream dataset resolvers.

policyengine_uk/simulation.py

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,13 @@ def __init__(
105105
scenario: Optional[Scenario] = None,
106106
situation: Optional[Dict] = None,
107107
dataset: Optional[
108-
Union[pd.DataFrame, str, UKSingleYearDataset, UKMultiYearDataset]
108+
Union[
109+
pd.DataFrame,
110+
str,
111+
os.PathLike,
112+
UKSingleYearDataset,
113+
UKMultiYearDataset,
114+
]
109115
] = None,
110116
trace: bool = False,
111117
reform: Union[Dict, Type[Reform]] = None,
@@ -148,8 +154,8 @@ def __init__(
148154
# Build simulation from appropriate source
149155
if situation is not None:
150156
self.build_from_situation(situation)
151-
elif isinstance(dataset, str):
152-
self.build_from_url(dataset)
157+
elif isinstance(dataset, (str, os.PathLike)):
158+
self.build_from_dataset_source(dataset)
153159
elif isinstance(dataset, pd.DataFrame):
154160
self.build_from_dataframe(dataset)
155161
elif isinstance(dataset, Dataset):
@@ -159,7 +165,7 @@ def __init__(
159165
elif isinstance(dataset, UKMultiYearDataset):
160166
self.build_from_multi_year_dataset(dataset)
161167
elif dataset is None:
162-
self.build_from_url(get_default_dataset_url())
168+
self.build_from_dataset_source(get_default_dataset_url())
163169
else:
164170
raise ValueError(f"Unsupported dataset type: {dataset.__class__}")
165171

@@ -260,6 +266,52 @@ def build_from_situation(self, situation: Dict) -> None:
260266
self, fiscal_year=self.default_input_period
261267
)
262268

269+
def build_from_dataset_source(
270+
self, dataset_source: Union[str, os.PathLike]
271+
) -> None:
272+
"""Build simulation from a supported dataset source string."""
273+
dataset_source = os.fspath(dataset_source)
274+
if dataset_source.startswith("hf://"):
275+
self.build_from_url(dataset_source)
276+
return
277+
if "://" in dataset_source:
278+
raise ValueError(
279+
"Only HuggingFace dataset URLs are supported directly by "
280+
"policyengine-uk. Download or materialize other dataset "
281+
"sources to a local file path before passing them to Simulation."
282+
)
283+
self.build_from_file(dataset_source)
284+
285+
def build_from_file(
286+
self,
287+
dataset_file: Union[str, os.PathLike],
288+
*,
289+
cache_key: Optional[str] = None,
290+
) -> None:
291+
"""Build simulation from a local dataset file path."""
292+
dataset_file = os.fspath(dataset_file)
293+
294+
if UKMultiYearDataset.validate_file_path(dataset_file, False):
295+
multi_year_dataset = UKMultiYearDataset(dataset_file)
296+
elif UKSingleYearDataset.validate_file_path(dataset_file, False):
297+
multi_year_dataset = extend_single_year_dataset(
298+
UKSingleYearDataset(dataset_file),
299+
self.tax_benefit_system.parameters,
300+
)
301+
else:
302+
dataset = Dataset.from_file(dataset_file, self.default_input_period)
303+
self.build_from_dataset(dataset)
304+
return
305+
306+
# Pre-encode string enum columns to int16 once before caching so
307+
# subsequent loads skip the expensive astype(str) + searchsorted path.
308+
_pre_encode_enum_columns(multi_year_dataset, self.tax_benefit_system)
309+
if cache_key is not None:
310+
_url_dataset_cache[cache_key] = multi_year_dataset
311+
312+
self.build_from_multi_year_dataset(multi_year_dataset)
313+
self.dataset = multi_year_dataset
314+
263315
def build_from_url(self, url: str) -> None:
264316
"""Build simulation from a HuggingFace dataset URL.
265317
@@ -269,7 +321,7 @@ def build_from_url(self, url: str) -> None:
269321
Raises:
270322
ValueError: If URL is not a HuggingFace URL
271323
"""
272-
if "hf://" not in url:
324+
if not url.startswith("hf://"):
273325
raise ValueError(f"Non-HuggingFace URLs are currently not supported.")
274326

275327
# Return early from in-memory cache if available: skips HDF5 reading,
@@ -295,26 +347,7 @@ def build_from_url(self, url: str) -> None:
295347
version=version,
296348
)
297349

298-
# Determine dataset type and build accordingly
299-
if UKMultiYearDataset.validate_file_path(dataset_file, False):
300-
multi_year_dataset = UKMultiYearDataset(dataset_file)
301-
elif UKSingleYearDataset.validate_file_path(dataset_file, False):
302-
multi_year_dataset = extend_single_year_dataset(
303-
UKSingleYearDataset(dataset_file),
304-
self.tax_benefit_system.parameters,
305-
)
306-
else:
307-
dataset = Dataset.from_file(dataset_file, self.default_input_period)
308-
self.build_from_dataset(dataset)
309-
return
310-
311-
# Pre-encode string enum columns to int16 once before caching so
312-
# subsequent loads skip the expensive astype(str) + searchsorted path.
313-
_pre_encode_enum_columns(multi_year_dataset, self.tax_benefit_system)
314-
_url_dataset_cache[url] = multi_year_dataset
315-
316-
self.build_from_multi_year_dataset(multi_year_dataset)
317-
self.dataset = multi_year_dataset
350+
self.build_from_file(dataset_file, cache_key=url)
318351

319352
def build_from_dataframe(self, df: pd.DataFrame) -> None:
320353
"""Build simulation from a pandas DataFrame.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
import policyengine_uk.simulation as simulation_module
6+
from policyengine_uk import Simulation
7+
8+
9+
class _StopDatasetLoad(Exception):
10+
pass
11+
12+
13+
@pytest.mark.parametrize(
14+
"dataset_source_factory",
15+
[str, lambda dataset_path: dataset_path],
16+
ids=["str", "pathlike"],
17+
)
18+
def test_simulation_routes_local_dataset_path_to_file_loader(
19+
monkeypatch, tmp_path, dataset_source_factory
20+
):
21+
captured = {}
22+
dataset_path = tmp_path / "dataset.h5"
23+
24+
def fake_build_from_file(self, dataset_file, *, cache_key=None):
25+
captured["dataset_file"] = dataset_file
26+
captured["cache_key"] = cache_key
27+
raise _StopDatasetLoad
28+
29+
monkeypatch.setattr(Simulation, "build_from_file", fake_build_from_file)
30+
31+
with pytest.raises(_StopDatasetLoad):
32+
Simulation(dataset=dataset_source_factory(dataset_path))
33+
34+
assert captured == {
35+
"dataset_file": str(dataset_path),
36+
"cache_key": None,
37+
}
38+
39+
40+
def test_dataset_source_routes_huggingface_urls_to_url_loader(monkeypatch):
41+
captured = {}
42+
simulation = Simulation.__new__(Simulation)
43+
url = "hf://policyengine/policyengine-uk-data/enhanced_frs_2022_23.h5"
44+
45+
def fake_build_from_url(dataset_url):
46+
captured["url"] = dataset_url
47+
48+
def fake_build_from_file(dataset_file, *, cache_key=None):
49+
raise AssertionError("HuggingFace URLs should not be treated as local files.")
50+
51+
monkeypatch.setattr(simulation, "build_from_url", fake_build_from_url)
52+
monkeypatch.setattr(simulation, "build_from_file", fake_build_from_file)
53+
54+
Simulation.build_from_dataset_source(simulation, url)
55+
56+
assert captured == {"url": url}
57+
58+
59+
def test_dataset_source_rejects_unsupported_remote_urls():
60+
simulation = Simulation.__new__(Simulation)
61+
62+
with pytest.raises(ValueError, match="Only HuggingFace dataset URLs"):
63+
Simulation.build_from_dataset_source(
64+
simulation,
65+
"gs://policyengine-uk-data-private/enhanced_frs_2023_24.h5",
66+
)
67+
68+
69+
def test_build_from_file_loads_local_multi_year_dataset(monkeypatch, tmp_path):
70+
cache_key = "hf://policyengine/policyengine-uk-data/enhanced_frs_2022_23.h5"
71+
dataset_path = tmp_path / "dataset.h5"
72+
built = {}
73+
74+
class FakeMultiYearDataset:
75+
@staticmethod
76+
def validate_file_path(file_path, raise_exception=False):
77+
built["validated_file_path"] = file_path
78+
built["raise_exception"] = raise_exception
79+
return True
80+
81+
def __init__(self, file_path):
82+
self.file_path = file_path
83+
84+
def fake_build_from_multi_year_dataset(dataset):
85+
built["dataset"] = dataset
86+
87+
simulation_module._url_dataset_cache.pop(cache_key, None)
88+
monkeypatch.setattr(simulation_module, "UKMultiYearDataset", FakeMultiYearDataset)
89+
monkeypatch.setattr(
90+
simulation_module, "_pre_encode_enum_columns", lambda dataset, tbs: None
91+
)
92+
93+
simulation = Simulation.__new__(Simulation)
94+
simulation.tax_benefit_system = object()
95+
monkeypatch.setattr(
96+
simulation,
97+
"build_from_multi_year_dataset",
98+
fake_build_from_multi_year_dataset,
99+
)
100+
101+
try:
102+
Simulation.build_from_file(simulation, Path(dataset_path), cache_key=cache_key)
103+
104+
assert built["validated_file_path"] == str(dataset_path)
105+
assert built["raise_exception"] is False
106+
assert built["dataset"].file_path == str(dataset_path)
107+
assert simulation.dataset is built["dataset"]
108+
assert simulation_module._url_dataset_cache[cache_key] is built["dataset"]
109+
finally:
110+
simulation_module._url_dataset_cache.pop(cache_key, None)

0 commit comments

Comments
 (0)