Skip to content

Commit 4bda42f

Browse files
authored
Merge pull request #15 from iterorganization/feature/netcdf-file-consumer
netCDF consumer
2 parents 7e9477b + dfaf9e8 commit 4bda42f

9 files changed

Lines changed: 285 additions & 72 deletions

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ dynamic = ["version"]
2121
requires-python = ">=3.11"
2222

2323
[project.optional-dependencies]
24-
all = ["imas-streams[dev,kafka,muscle3]"]
24+
all = ["imas-streams[dev,kafka,muscle3,xarray,netcdf]"]
2525
kafka = ["confluent-kafka"]
2626
muscle3 = ["muscle3 >= 0.10.0", "imas-streams[kafka]"]
27+
xarray = ["xarray"]
28+
netcdf = ["netCDF4"]
2729
dev = [
2830
"ruff",
2931
"pytest",

src/imas_streams/cli.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from pathlib import Path
23

34
import click
45
import imas
@@ -153,7 +154,7 @@ def kafka_to_imasentry(
153154
overwrite: bool,
154155
timeout: float,
155156
):
156-
"""Consume streaming IMAS data from Kafka and store data in an IMAS Data Entry.
157+
"""Consume streaming IMAS data from Kafka and store the data in an IMAS Data Entry.
157158
158159
\b
159160
Arguments:
@@ -181,6 +182,53 @@ def kafka_to_imasentry(
181182
entry.put_slice(result)
182183

183184

185+
@main.command
186+
@click.argument("kafka_host")
187+
@click.argument("topic")
188+
@click.argument("filename")
189+
@click.option(
190+
"--batch-size",
191+
default=1024,
192+
help="Number of time slices to batch when writing data to disk",
193+
)
194+
@click.option("--overwrite", is_flag=True, help="Overwrite any existing file")
195+
@click.option("--timeout", "-t", default=5.0, help="Timeout for receiving next message")
196+
def kafka_to_netcdf(
197+
kafka_host: str,
198+
topic: str,
199+
filename: str,
200+
batch_size: int,
201+
overwrite: bool,
202+
timeout: float,
203+
):
204+
"""Consume streaming IMAS data from Kafka and store the data in an IMAS netCDF file.
205+
206+
\b
207+
Arguments:
208+
KAFKA_HOST Kafka host and port (aka bootstrap.servers). E.g. 'localhost:9092'.
209+
TOPIC Name of the kafka topic with streaming IMAS data.
210+
FILENAME Name of the NetCDF file to write the data to.
211+
"""
212+
# Local import: kafka and netCDF are optional dependencies
213+
from imas_streams.kafka import KafkaConsumer, KafkaSettings
214+
from imas_streams.netcdf_consumers import NetCDFConsumer
215+
216+
fpath = Path(filename)
217+
if overwrite and fpath.exists():
218+
logging.info("Removing existing file '%s'...")
219+
fpath.unlink()
220+
221+
consumer = KafkaConsumer(
222+
KafkaSettings(host=kafka_host, topic_name=topic),
223+
NetCDFConsumer,
224+
filename=fpath,
225+
batch_size=batch_size,
226+
)
227+
228+
for _ in consumer.stream(timeout=timeout):
229+
pass # The NetCDFConsumer does everything, but we need to loop over the stream
230+
231+
184232
def _ensure_kafka_muscle3_dependencies():
185233
"""Ensure optional dependencies are available"""
186234
# Ensure optional dependencies are available
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import logging
2+
from pathlib import Path
3+
4+
from imas_streams import StreamingIMASMetadata
5+
from imas_streams.xarray_consumers import StreamingXArrayConsumer
6+
7+
logger = logging.getLogger(__name__)
8+
try:
9+
import netCDF4
10+
import xarray
11+
except ImportError:
12+
logger.error("Optional dependency 'netCDF4' or 'xarray' is not installed.")
13+
raise
14+
15+
16+
class NetCDFConsumer:
17+
"""Consumer of streaming IMAS data which stores the data in a netCDF file.
18+
19+
Note: writes to the filesystem are batched for better performance. The first data
20+
will be stored once the first batch is complete.
21+
22+
Example:
23+
.. code-block:: python
24+
25+
# Create metadata (from JSON)
26+
metadata = StreamingIMASMetadata.model_validate_json(json_metadata)
27+
# Create reader
28+
reader = NetCDFConsumer(metadata, filename="output.nc", batch_size=1024)
29+
30+
# Consume dynamic data
31+
for dynamic_data in dynamic_data_stream:
32+
reader.process_message(dynamic_data)
33+
reader.finalize()
34+
"""
35+
36+
def __init__(
37+
self, metadata: StreamingIMASMetadata, *, filename: Path, batch_size: int = 1024
38+
) -> None:
39+
self._metadata = metadata
40+
self._groupname = f"{metadata.ids_name}/0"
41+
self._filename = filename
42+
self._batch_size = batch_size
43+
44+
# Touch the file so we know that it exists
45+
try:
46+
self._filename.touch(exist_ok=False)
47+
except FileExistsError as exc:
48+
exc.add_note(
49+
"NetCDFConsumer will not overwrite existing files. Please rename or "
50+
f"remove {filename} and try again."
51+
)
52+
raise
53+
self._netcdf_file = None
54+
self._time_variables = []
55+
56+
# Let the xarray consumer handle all buffering and tensorization:
57+
self._xarray_consumer = StreamingXArrayConsumer(metadata, batch_size=batch_size)
58+
59+
def _store_data(self, ds: xarray.Dataset) -> None:
60+
"""Store data to netCDF file"""
61+
if self._netcdf_file is None:
62+
# Check which variables are time-dependent
63+
for varname in ds.variables:
64+
if "time" in ds[varname].dims:
65+
self._time_variables.append(varname)
66+
67+
# Compress dynamic data
68+
encoding = {
69+
name: {
70+
"compression": "zlib",
71+
"complevel": 1,
72+
"chunksizes": self._chunksize(ds[name]),
73+
}
74+
for name in self._time_variables
75+
if ds[name].dtype.kind in "if"
76+
}
77+
78+
# Use xarray API to create the netCDF file
79+
ds.to_netcdf(
80+
self._filename,
81+
"w",
82+
format="NETCDF4",
83+
engine="netcdf4",
84+
group=self._groupname,
85+
auto_complex=True,
86+
unlimited_dims=["time"],
87+
encoding=encoding,
88+
)
89+
self._netcdf_file = netCDF4.Dataset(self._filename, "r+", auto_complex=True)
90+
self._netcdf_file.set_fill_off() # Don't fill on resize
91+
# Set mandatory global metadata attributes
92+
self._netcdf_file.Conventions = "IMAS"
93+
self._netcdf_file.data_dictionary_version = (
94+
self._metadata.data_dictionary_version
95+
)
96+
97+
else:
98+
# Add time slices to netCDF file
99+
group = self._netcdf_file[self._groupname]
100+
timeslice = slice(len(group.dimensions["time"]), None)
101+
allslice = slice(None)
102+
for varname in self._time_variables:
103+
xrvar = ds[varname]
104+
index = tuple(
105+
timeslice if dim == "time" else allslice for dim in xrvar.dims
106+
)
107+
group[varname][index] = xrvar.data
108+
109+
def _chunksize(self, array: xarray.DataArray) -> tuple[int, ...]:
110+
"""Heuristic to determine chunksizes for storing the provided data array"""
111+
if array.nbytes > 4_194_304: # 4 MB
112+
logger.warning(
113+
"Huge chunk detected due to batchsize for %s: %s (=%d bytes)",
114+
array.name,
115+
array.shape,
116+
array.nbytes,
117+
)
118+
return array.shape
119+
120+
def process_message(self, data: bytes | bytearray) -> None:
121+
"""Process a dynamic data message and store it (when a batch is full)."""
122+
result = self._xarray_consumer.process_message(data)
123+
if result is not None:
124+
self._store_data(result)
125+
126+
def finalize(self) -> None:
127+
"""Indicate that the final message is received and store any remaining data."""
128+
result = self._xarray_consumer.finalize()
129+
if result is not None:
130+
self._store_data(result)
131+
if self._netcdf_file is not None:
132+
self._netcdf_file.close()

src/imas_streams/xarray_consumers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ def __init__(self, metadata: StreamingIMASMetadata, *, batch_size: int = 1) -> N
122122
if path in self._dataset.indexes:
123123
# Prevent xarray from creating a copy of the data:
124124
tensorview = Index(tensorview, copy=False)
125-
to_update[path] = (xrda.dims, tensorview)
125+
to_update[path] = (xrda.dims, tensorview, xrda.attrs)
126126
tensor_idx += size
127127
self._dataset = self._dataset.assign(to_update)
128128
# Check that all data arrays are indeed views of our tensor buffer:

tests/conftest.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,43 @@
11
import os
22

3+
import imas
34
import pytest
5+
from imas.ids_defs import IDS_TIME_MODE_HOMOGENEOUS
6+
7+
from imas_streams import StreamingIMASMetadata
8+
from imas_streams.metadata import DynamicData
9+
10+
DD_VERSION = os.getenv("IMAS_VERSION", "4.0.0")
11+
12+
13+
@pytest.fixture
14+
def magnetics_metadata():
15+
ids = imas.IDSFactory(DD_VERSION).new("magnetics")
16+
17+
ids.ids_properties.homogeneous_time = IDS_TIME_MODE_HOMOGENEOUS
18+
ids.time = [0.0]
19+
20+
ids.flux_loop.resize(5)
21+
for i, loop in enumerate(ids.flux_loop):
22+
loop.name = f"flux_loop_{i}"
23+
loop.position.resize(1)
24+
loop.position[0].r = i / 2
25+
loop.position[0].z = i / 2
26+
27+
return StreamingIMASMetadata(
28+
data_dictionary_version=DD_VERSION,
29+
ids_name="magnetics",
30+
static_data=ids,
31+
dynamic_data=[
32+
DynamicData(path="time", shape=(1,), data_type="f64"),
33+
DynamicData(path="flux_loop[0]/flux/data", shape=(1,), data_type="f64"),
34+
DynamicData(path="flux_loop[1]/flux/data", shape=(1,), data_type="f64"),
35+
DynamicData(path="flux_loop[2]/flux/data", shape=(1,), data_type="f64"),
36+
DynamicData(path="flux_loop[3]/flux/data", shape=(1,), data_type="f64"),
37+
DynamicData(path="flux_loop[4]/flux/data", shape=(1,), data_type="f64"),
38+
DynamicData(path="flux_loop[0]/voltage/data", shape=(1,), data_type="f64"),
39+
],
40+
)
441

542

643
@pytest.fixture

tests/test_ids_consumer.py

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import os
2-
31
import imas
42
import numpy as np
53
import pytest
@@ -8,37 +6,7 @@
86
from imas_streams import BatchedIDSConsumer, StreamingIDSConsumer, StreamingIMASMetadata
97
from imas_streams.metadata import DynamicData
108

11-
DD_VERSION = os.getenv("IMAS_VERSION", "4.0.0")
12-
13-
14-
@pytest.fixture
15-
def magnetics_metadata():
16-
ids = imas.IDSFactory(DD_VERSION).new("magnetics")
17-
18-
ids.ids_properties.homogeneous_time = IDS_TIME_MODE_HOMOGENEOUS
19-
ids.time = [0.0]
20-
21-
ids.flux_loop.resize(5)
22-
for i, loop in enumerate(ids.flux_loop):
23-
loop.name = f"flux_loop_{i}"
24-
loop.position.resize(1)
25-
loop.position[0].r = i / 2
26-
loop.position[0].z = i / 2
27-
28-
return StreamingIMASMetadata(
29-
data_dictionary_version=DD_VERSION,
30-
ids_name="magnetics",
31-
static_data=ids,
32-
dynamic_data=[
33-
DynamicData(path="time", shape=(1,), data_type="f64"),
34-
DynamicData(path="flux_loop[0]/flux/data", shape=(1,), data_type="f64"),
35-
DynamicData(path="flux_loop[1]/flux/data", shape=(1,), data_type="f64"),
36-
DynamicData(path="flux_loop[2]/flux/data", shape=(1,), data_type="f64"),
37-
DynamicData(path="flux_loop[3]/flux/data", shape=(1,), data_type="f64"),
38-
DynamicData(path="flux_loop[4]/flux/data", shape=(1,), data_type="f64"),
39-
DynamicData(path="flux_loop[0]/voltage/data", shape=(1,), data_type="f64"),
40-
],
41-
)
9+
from .conftest import DD_VERSION
4210

4311

4412
def test_ids_consumer(magnetics_metadata):

tests/test_ids_stream.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from imas.ids_defs import CLOSEST_INTERP
55

66
from imas_streams import BatchedIDSConsumer, StreamingIDSConsumer, StreamingIDSProducer
7+
from imas_streams.netcdf_consumers import NetCDFConsumer
78
from imas_streams.xarray_consumers import StreamingXArrayConsumer
89

910

@@ -112,3 +113,37 @@ def test_stream_core_profiles_xarray_batched(testdb):
112113
# Check that the data is identical
113114
assert xrds_deserialized is not None
114115
assert xrds_orig.equals(xrds_deserialized)
116+
117+
118+
@pytest.mark.parametrize("batch_size", [1, 2, 3, 4])
119+
def test_stream_core_profiles_netcdf(testdb, tmp_path, batch_size):
120+
ids_name = "core_profiles"
121+
times = testdb.get(ids_name, lazy=True).time.value
122+
first_slice = testdb.get_slice(ids_name, times[0], CLOSEST_INTERP)
123+
producer = StreamingIDSProducer(first_slice, static_paths=cp_static_paths)
124+
fname = tmp_path / "test.nc"
125+
consumer = NetCDFConsumer(producer.metadata, filename=fname, batch_size=batch_size)
126+
127+
for t in times:
128+
time_slice = testdb.get_slice(ids_name, t, CLOSEST_INTERP)
129+
data = producer.create_message(time_slice)
130+
131+
result = consumer.process_message(data)
132+
assert result is None
133+
result = consumer.finalize()
134+
assert result is None
135+
136+
# Check that the file is as expected
137+
with imas.DBEntry(str(fname), "r") as entry:
138+
ids = entry.get(ids_name)
139+
140+
# Compare against full IDS
141+
ids_orig = testdb.get(ids_name)
142+
# N.B. imas.util.to_xarray doesn't include metadata for inhomogeneously sized AoS
143+
# so we ignore all differences for the size of profiles_1d/ion/state AoS
144+
diffs = [
145+
diff
146+
for diff in imas.util.idsdiffgen(ids, ids_orig)
147+
if diff[0] != "profiles_1d/ion/state"
148+
]
149+
assert diffs == []

tests/test_netcdf_consumer.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import imas
2+
import numpy as np
3+
import pytest
4+
5+
from imas_streams.netcdf_consumers import NetCDFConsumer
6+
7+
8+
@pytest.mark.parametrize("batch_size", [1, 2, 5, 7, 10, 13, 20])
9+
def test_netcdf_consumer(magnetics_metadata, tmp_path, batch_size):
10+
fname = tmp_path / "test.nc"
11+
reader = NetCDFConsumer(magnetics_metadata, filename=fname, batch_size=batch_size)
12+
13+
# Pretend sending 20 messages
14+
for i in range(20):
15+
test_data = np.arange(len(magnetics_metadata.dynamic_data), dtype="<f8") + i
16+
dataset = reader.process_message(test_data.tobytes())
17+
# Only expect a result after batch_size items are processed
18+
assert dataset is None
19+
reader.finalize()
20+
21+
# Check that the file is as expected
22+
with imas.DBEntry(str(fname), "r") as entry:
23+
ids = entry.get("magnetics")
24+
25+
assert np.array_equal(ids.time, np.arange(20, dtype=float))
26+
assert len(ids.flux_loop) == 5
27+
assert np.array_equal(ids.flux_loop[0].flux.data, np.arange(1, 21, dtype=float))

0 commit comments

Comments
 (0)