Skip to content

Commit aa1a3ff

Browse files
Copilotdhensle
andauthored
Add regression tests for nullable_nonnegative decode filter in write_tables (#1087)
* adding nullable_nonnegative decode filter * Initial plan * Add unit tests for _decode_output_column and _apply_decode_filter (nullable_nonnegative) --------- Co-authored-by: David Hensle <51132108+dhensle@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 3bcc811 commit aa1a3ff

5 files changed

Lines changed: 139 additions & 16 deletions

File tree

activitysim/core/configuration/top.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ class OutputTable(PydanticBase):
114114
should be decoded, as negative values indicate an absence of choice. In
115115
these cases, the "tablename.fieldname" can be prefixed with a "nonnegative"
116116
filter, seperated by a pipe character (e.g. "nonnegative | land_use.zone_id").
117+
If the column may also contain null values, use "nullable_nonnegative"
118+
instead to pass nulls through unchanged while still preserving negative
119+
sentinel values.
117120
"""
118121

119122

activitysim/core/steps/_decode.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# ActivitySim
2+
# See full license in LICENSE.txt.
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import pandas as pd
7+
8+
9+
def _decode_output_column(column, map_func, preserve_nulls=False):
10+
series = pd.Series(column)
11+
if preserve_nulls:
12+
revised_col = series.copy()
13+
notna = series.notna()
14+
revised_col.loc[notna] = series.loc[notna].astype(int).map(map_func)
15+
return revised_col
16+
return series.astype(int).map(map_func)
17+
18+
19+
def _apply_decode_filter(map_col, decode_filter):
20+
preserve_nulls = False
21+
22+
if decode_filter is None:
23+
return map_col.__getitem__, preserve_nulls
24+
25+
if decode_filter == "nonnegative":
26+
27+
def map_func(x):
28+
return x if x < 0 else map_col[x]
29+
30+
return map_func, preserve_nulls
31+
32+
if decode_filter == "nullable_nonnegative":
33+
preserve_nulls = True
34+
35+
def map_func(x):
36+
return x if x < 0 else map_col[x]
37+
38+
return map_func, preserve_nulls
39+
40+
raise ValueError(f"unknown decode_filter {decode_filter}")

activitysim/core/steps/output.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from activitysim.core import configuration, workflow
1818
from activitysim.core.workflow.checkpoint import CHECKPOINT_NAME
1919
from activitysim.core.estimation import estimation_enabled, EstimationConfig
20+
from activitysim.core.steps._decode import _apply_decode_filter, _decode_output_column
2021

2122
logger = logging.getLogger(__name__)
2223

@@ -515,9 +516,11 @@ def write_tables(state: workflow.State) -> None:
515516

516517
if decode_instruction == "time_period":
517518
map_col = list(state.network_settings.skim_time_periods.labels)
518-
map_func = map_col.__getitem__
519-
revised_col = (
520-
pd.Series(dt.column(colname)).astype(int).map(map_func)
519+
map_func, preserve_nulls = _apply_decode_filter(
520+
map_col, decode_filter
521+
)
522+
revised_col = _decode_output_column(
523+
dt.column(colname), map_func, preserve_nulls=preserve_nulls
521524
)
522525
dt = dt.drop([colname]).append_column(
523526
colname, pa.array(revised_col)
@@ -536,18 +539,10 @@ def write_tables(state: workflow.State) -> None:
536539
except KeyError:
537540
map_col = parent_table.column(lookup_col)
538541
map_col = np.asarray(map_col)
539-
map_func = map_col.__getitem__
540-
if decode_filter:
541-
if decode_filter == "nonnegative":
542-
543-
def map_func(x):
544-
return x if x < 0 else map_col[x]
545-
546-
else:
547-
raise ValueError(f"unknown decode_filter {decode_filter}")
542+
map_func, preserve_nulls = _apply_decode_filter(map_col, decode_filter)
548543
if colname in dt.column_names:
549-
revised_col = (
550-
pd.Series(dt.column(colname)).astype(int).map(map_func)
544+
revised_col = _decode_output_column(
545+
dt.column(colname), map_func, preserve_nulls=preserve_nulls
551546
)
552547
dt = dt.drop([colname]).append_column(
553548
colname, pa.array(revised_col)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# ActivitySim
2+
# See full license in LICENSE.txt.
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import pandas as pd
7+
import pandas.testing as pdt
8+
import pytest
9+
10+
from activitysim.core.steps._decode import _apply_decode_filter, _decode_output_column
11+
12+
13+
@pytest.fixture
14+
def zone_labels():
15+
"""Sample zone ID lookup array (index 0..4 -> zone labels)."""
16+
return np.array([100, 200, 300, 400, 500])
17+
18+
19+
def test_decode_output_column_no_nulls(zone_labels):
20+
"""Basic decode without nulls: each integer index maps to the zone label."""
21+
map_func = zone_labels.__getitem__
22+
column = pd.array([0, 1, 2, 3, 4])
23+
result = _decode_output_column(column, map_func)
24+
expected = pd.Series([100, 200, 300, 400, 500])
25+
pdt.assert_series_equal(result, expected)
26+
27+
28+
def test_decode_output_column_preserve_nulls(zone_labels):
29+
"""With preserve_nulls=True, NaN entries pass through unchanged."""
30+
map_func = zone_labels.__getitem__
31+
column = pd.array([0, pd.NA, 2, pd.NA, 4], dtype="Int64")
32+
result = _decode_output_column(column, map_func, preserve_nulls=True)
33+
# Non-null positions are decoded; null positions remain null.
34+
assert result[0] == 100
35+
assert result[2] == 300
36+
assert result[4] == 500
37+
assert pd.isna(result[1])
38+
assert pd.isna(result[3])
39+
40+
41+
def test_apply_decode_filter_none(zone_labels):
42+
"""No filter: map_func is direct indexing and preserve_nulls is False."""
43+
map_func, preserve_nulls = _apply_decode_filter(zone_labels, None)
44+
assert not preserve_nulls
45+
assert map_func(2) == zone_labels[2]
46+
47+
48+
def test_apply_decode_filter_nonnegative(zone_labels):
49+
"""nonnegative filter: negative values pass through; others are decoded."""
50+
map_func, preserve_nulls = _apply_decode_filter(zone_labels, "nonnegative")
51+
assert not preserve_nulls
52+
assert map_func(1) == 200
53+
assert map_func(-1) == -1
54+
55+
56+
def test_apply_decode_filter_nullable_nonnegative(zone_labels):
57+
"""nullable_nonnegative filter: negative values pass through; preserve_nulls is True."""
58+
map_func, preserve_nulls = _apply_decode_filter(zone_labels, "nullable_nonnegative")
59+
assert preserve_nulls
60+
assert map_func(1) == 200
61+
assert map_func(-1) == -1
62+
63+
64+
def test_apply_decode_filter_unknown():
65+
"""Unknown filter name raises ValueError."""
66+
with pytest.raises(ValueError, match="unknown decode_filter"):
67+
_apply_decode_filter([], "bad_filter")
68+
69+
70+
def test_nullable_nonnegative_with_nulls_and_sentinels(zone_labels):
71+
"""
72+
End-to-end: a column with nulls and -1 sentinel values decoded via
73+
nullable_nonnegative should pass nulls and negative values through unchanged
74+
while mapping non-negative indexes to zone labels.
75+
"""
76+
map_func, preserve_nulls = _apply_decode_filter(zone_labels, "nullable_nonnegative")
77+
column = pd.array([0, pd.NA, -1, 3, pd.NA], dtype="Int64")
78+
result = _decode_output_column(column, map_func, preserve_nulls=preserve_nulls)
79+
assert result[0] == 100
80+
assert pd.isna(result[1])
81+
assert result[2] == -1
82+
assert result[3] == 400
83+
assert pd.isna(result[4])

docs/dev-guide/using-sharrow.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,9 @@ decoded in various output files. Generally, for columns that are fully populate
111111
with zone id's (e.g. tour and trip ends) we can apply the `decode_columns` settings
112112
to reverse the mapping and restore the nominal zone id's globally for the entire
113113
column of data. For columns where there is some missing data flagged by negative
114-
values, the "nonnegative" filter is prepended to the instruction.
114+
values, the "nonnegative" filter is prepended to the instruction. If the column
115+
may also contain nulls, use "nullable_nonnegative" to leave those values
116+
unchanged while decoding the non-negative indexes.
115117

116118
```yaml
117119
output_tables:
@@ -127,7 +129,7 @@ output_tables:
127129
decode_columns:
128130
home_zone_id: land_use.zone_id
129131
school_zone_id: nonnegative | land_use.zone_id
130-
workplace_zone_id: nonnegative | land_use.zone_id
132+
workplace_zone_id: nullable_nonnegative | land_use.zone_id
131133
- tablename: tours
132134
decode_columns:
133135
origin: land_use.zone_id

0 commit comments

Comments
 (0)