Skip to content

Commit cbb7e70

Browse files
authored
Merge pull request #27 from bcdev/forman-x-flags_rule
Added core rule "flags"
2 parents 222e33f + 1432546 commit cbb7e70

File tree

6 files changed

+266
-1
lines changed

6 files changed

+266
-1
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Version 0.3.0 (in development)
44

5+
- Added cure rule "flags"
6+
57
- Fixed problem where referring to values in modules via
68
the form `"<module>:<attr>"` raised. #21
79

docs/rule-ref.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ Datasets should be given a non-empty title.
1717

1818
Contained in: `all`-:material-lightning-bolt: `recommended`-:material-alert:
1919

20+
### :material-lightbulb: `flags`
21+
22+
Validate attributes 'flag_values', 'flag_masks' and 'flag_meanings' that make variables that contain flag values self describing.
23+
[:material-information-variant:](https://cfconventions.org/cf-conventions/cf-conventions.html#flags)
24+
25+
Contained in: `all`-:material-lightning-bolt:
26+
2027
### :material-bug: `grid-mappings`
2128

2229
Grid mappings, if any, shall have valid grid mapping coordinate variables.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import numpy as np
2+
import xarray as xr
3+
4+
from xrlint.plugins.core.rules.flags import Flags
5+
from xrlint.testing import RuleTester, RuleTest
6+
7+
valid_dataset_0 = xr.Dataset()
8+
valid_dataset_1 = xr.Dataset(
9+
attrs=dict(title="sensor-data"),
10+
data_vars={
11+
"sensor_status_qc": xr.DataArray(
12+
[1, 3, 5, 2, 0, 5],
13+
dims="x",
14+
attrs=dict(
15+
long_name="Sensor Status",
16+
standard_name="status_flag",
17+
_FillValue=0,
18+
valid_range=[1, 15],
19+
flag_masks=[1, 2, 12, 12, 12],
20+
flag_values=[1, 2, 4, 8, 12],
21+
flag_meanings=(
22+
"low_battery"
23+
" hardware_fault"
24+
" offline_mode"
25+
" calibration_mode"
26+
" maintenance_mode"
27+
),
28+
),
29+
)
30+
},
31+
)
32+
33+
# Valid, because and flag_values and flag_meanings are sufficient
34+
valid_dataset_2 = valid_dataset_1.copy()
35+
del valid_dataset_2.sensor_status_qc.attrs["flag_masks"]
36+
37+
# Valid, because and flag_masks and flag_meanings are sufficient
38+
valid_dataset_3 = valid_dataset_1.copy()
39+
del valid_dataset_3.sensor_status_qc.attrs["flag_values"]
40+
41+
# Invalid, because flag_meanings are needed
42+
invalid_dataset_0 = valid_dataset_1.copy()
43+
del invalid_dataset_0.sensor_status_qc.attrs["flag_meanings"]
44+
45+
# Invalid, because flag_values are invalid
46+
invalid_dataset_1 = valid_dataset_1.copy()
47+
invalid_dataset_1.sensor_status_qc.attrs["flag_values"] = "1, 2, 4, 8, 12"
48+
49+
# Invalid, because flag_masks are invalid
50+
invalid_dataset_2 = valid_dataset_1.copy()
51+
invalid_dataset_2.sensor_status_qc.attrs["flag_masks"] = "1, 2, 12, 12, 12"
52+
53+
# Invalid, because flag_values and flag_masks must have same length
54+
invalid_dataset_3 = valid_dataset_1.copy()
55+
invalid_dataset_3.sensor_status_qc.attrs["flag_masks"] = [1, 2, 12]
56+
57+
# Invalid, because missing flag_values and flag_masks
58+
invalid_dataset_4 = valid_dataset_1.copy()
59+
del invalid_dataset_4.sensor_status_qc.attrs["flag_values"]
60+
del invalid_dataset_4.sensor_status_qc.attrs["flag_masks"]
61+
62+
# Invalid, because flag_meanings type is not ok
63+
invalid_dataset_5 = valid_dataset_1.copy()
64+
invalid_dataset_5.sensor_status_qc.attrs["flag_meanings"] = [1, 2, 12, 12, 12]
65+
66+
# Invalid, because flag_meanings length is not ok
67+
invalid_dataset_6 = valid_dataset_1.copy()
68+
invalid_dataset_6.sensor_status_qc.attrs["flag_meanings"] = "a b c"
69+
70+
# Invalid, because flag variable is not int
71+
invalid_dataset_7 = valid_dataset_1.copy()
72+
invalid_dataset_7["sensor_status_qc"] = valid_dataset_1.sensor_status_qc.astype(
73+
np.float64
74+
)
75+
76+
FlagsTest = RuleTester.define_test(
77+
"flags",
78+
Flags,
79+
valid=[
80+
RuleTest(dataset=valid_dataset_0),
81+
RuleTest(dataset=valid_dataset_1),
82+
RuleTest(dataset=valid_dataset_2),
83+
RuleTest(dataset=valid_dataset_3),
84+
],
85+
invalid=[
86+
RuleTest(dataset=invalid_dataset_0),
87+
RuleTest(dataset=invalid_dataset_1),
88+
RuleTest(dataset=invalid_dataset_2),
89+
RuleTest(dataset=invalid_dataset_3),
90+
RuleTest(dataset=invalid_dataset_4),
91+
RuleTest(dataset=invalid_dataset_5),
92+
RuleTest(dataset=invalid_dataset_6),
93+
RuleTest(dataset=invalid_dataset_7),
94+
],
95+
)

tests/plugins/core/test_plugin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def test_rules_complete(self):
2222
"coords-for-dims",
2323
"dataset-title-attr",
2424
"grid-mappings",
25+
"flags",
2526
"no-empty-attrs",
2627
"var-units-attr",
2728
},

xrlint/plugins/core/rules/flags.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
from typing import Any
2+
3+
import numpy as np
4+
5+
from xrlint.node import DataArrayNode
6+
from xrlint.plugins.core.rules import plugin
7+
from xrlint.rule import RuleOp, RuleContext
8+
9+
10+
FLAG_MEANINGS = "flag_meanings"
11+
FLAG_VALUES = "flag_values"
12+
FLAG_MASKS = "flag_masks"
13+
14+
15+
@plugin.define_rule(
16+
"flags",
17+
version="1.0.0",
18+
type="suggestion",
19+
description=(
20+
"Validate attributes 'flag_values', 'flag_masks' and 'flag_meanings'"
21+
" that make variables that contain flag values self describing. "
22+
),
23+
docs_url="https://cfconventions.org/cf-conventions/cf-conventions.html#flags",
24+
)
25+
class Flags(RuleOp):
26+
def data_array(self, ctx: RuleContext, node: DataArrayNode):
27+
flag_values = node.data_array.attrs.get(FLAG_VALUES)
28+
flag_masks = node.data_array.attrs.get(FLAG_MASKS)
29+
flag_meanings = node.data_array.attrs.get(FLAG_MEANINGS)
30+
31+
has_values = flag_values is not None
32+
has_masks = flag_masks is not None
33+
has_meanings = flag_meanings is not None
34+
35+
flag_count: int | None = None
36+
37+
if has_values:
38+
flag_count = _validate_flag_values(
39+
ctx,
40+
flag_values,
41+
has_meanings,
42+
)
43+
44+
if has_masks:
45+
flag_count = _validate_flag_masks(
46+
ctx,
47+
flag_masks,
48+
has_meanings,
49+
flag_count,
50+
)
51+
52+
if has_meanings:
53+
_validate_flag_meanings(
54+
ctx,
55+
flag_meanings,
56+
has_values,
57+
has_masks,
58+
flag_count,
59+
)
60+
61+
if has_values and has_masks:
62+
_validate_variable(
63+
ctx,
64+
node.data_array.dtype,
65+
)
66+
67+
68+
def _validate_flag_values(
69+
ctx: RuleContext, flag_values: Any, has_meanings: bool
70+
) -> int | None:
71+
if not has_meanings:
72+
ctx.report(
73+
f"Missing attribute {FLAG_MEANINGS!r} to explain"
74+
f" attribute {FLAG_VALUES!r}"
75+
)
76+
type_ok, flag_count = _check_values(flag_values)
77+
if not type_ok or flag_count is None:
78+
ctx.report(
79+
f"Attribute {FLAG_VALUES!r} must be a"
80+
" 1-d array of integers with length >= 1."
81+
)
82+
return flag_count
83+
84+
85+
def _validate_flag_masks(
86+
ctx: RuleContext, flag_masks: Any, has_meanings: bool, flag_count: int | None
87+
) -> int | None:
88+
if not has_meanings:
89+
ctx.report(
90+
f"Missing attribute {FLAG_MEANINGS!r} to explain"
91+
f" attribute {FLAG_MASKS!r}"
92+
)
93+
type_ok, flag_masks_count = _check_values(flag_masks)
94+
if not type_ok or flag_masks_count is None:
95+
ctx.report(
96+
f"Attribute {FLAG_MASKS!r} must be a"
97+
" 1-d array of integers with length >= 1."
98+
)
99+
if flag_count is None:
100+
flag_count = flag_masks_count
101+
elif flag_masks_count is not None and flag_masks_count != flag_count:
102+
ctx.report(
103+
f"Attribute {FLAG_MASKS!r} must have same length"
104+
f" as attribute {FLAG_VALUES!r}."
105+
)
106+
return flag_count
107+
108+
109+
def _validate_flag_meanings(
110+
ctx: RuleContext,
111+
flag_meanings: Any,
112+
has_values: bool,
113+
has_masks: bool,
114+
flag_count: int | None,
115+
):
116+
if not has_values and not has_masks:
117+
ctx.report(
118+
f"Missing attribute {FLAG_VALUES!r} or {FLAG_MASKS!r}"
119+
f" when attribute {FLAG_MASKS!r} is used."
120+
)
121+
type_ok, flag_meanings_count = _check_meanings(flag_meanings)
122+
if not type_ok or flag_meanings_count is None:
123+
ctx.report(
124+
f"Attribute {FLAG_MASKS!r} must be a space-separated string"
125+
f" with at least two entries."
126+
)
127+
if (
128+
flag_meanings_count is not None
129+
and flag_count is not None
130+
and flag_meanings_count != flag_count
131+
):
132+
ctx.report(
133+
f"Attribute {FLAG_MASKS!r} must have same length"
134+
f" as attributes {FLAG_VALUES!r} or {FLAG_MEANINGS!r}."
135+
)
136+
137+
138+
def _validate_variable(ctx: RuleContext, var_dtype: np.dtype):
139+
if not np.issubdtype(var_dtype, np.integer):
140+
ctx.report(
141+
f"Flags variable should have an integer data type, was {var_dtype!r}"
142+
)
143+
144+
145+
def _check_values(values: Any) -> tuple[bool, int | None]:
146+
if isinstance(values, (tuple, list)) or (
147+
isinstance(values, np.ndarray) and values.ndim == 1
148+
):
149+
count = len(values)
150+
return all(isinstance(v, int) for v in values), count if count >= 1 else None
151+
return False, None
152+
153+
154+
def _check_meanings(meanings: Any):
155+
if isinstance(meanings, str):
156+
meanings_list = [m.strip() for m in meanings.split(" ")]
157+
count = len(meanings_list)
158+
return True, count if count >= 1 else None
159+
return False, None

xrlint/testing.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,10 @@ def _format_error_message(
209209
actual = format_problems(result.error_count, result.warning_count)
210210
expected = f"{'no problem' if test_mode == 'valid' else 'one or more problems'}"
211211
messages = "\n".join(f"- {m.message}" for m in result.messages)
212+
messages = (":\n" + messages) if messages else "."
212213
return (
213214
f"Rule {rule_name!r}: {test_id}:"
214-
f" expected {expected}, but got {actual}{f':\n{messages}' if messages else '.'}"
215+
f" expected {expected}, but got {actual}{messages}"
215216
)
216217

217218

0 commit comments

Comments
 (0)