From 7a05ab40bf54bda3cafd387871cab7a3abda0662 Mon Sep 17 00:00:00 2001 From: Jim Schaff Date: Wed, 19 Aug 2026 23:40:32 -0400 Subject: [PATCH] Characterise what the HDF5 export writes ASCIIExporter has no tests, and its HDF5 writing is the least obvious part of it: raw simulation data is reordered by slice extraction before it lands in the file, and the dataset's name and shape both depend on what was asked for. Nothing recorded what a user actually downloads. Pin the three shapes SliceHelper.populate produces, a timepoint at a time: whole volume DataValues (XYZT) {outer=sizeY, inner=sizeX, sizeZ, time} single slice DataValues (XYT) {sizeY, sizeX, time} membrane DataValues (MT) {len, time}, written verbatim A single slice drops Z from the shape and from the dataset name, so it is a different dataset from a whole-volume export rather than a one-slice version of it. That is easy to get wrong and impossible to notice without a test. The membrane case needed a mesh reporting membrane elements. Those only come from the Geometry based factory with a generated surface collection, which is far more machinery than a test of "writes the array unchanged" should need, so CartesianMeshTestSupport sets the count from inside the mesh's own package. Worth knowing while reading it: the type is inferred from data length and VOLUME is checked first, and a zero length array still matches MEMBRANE -- so a mesh with no membranes lets such a test pass while measuring nothing. The assertions refuse that. Written against the existing native hdf.hdf5lib binding and read back with jhdf, so this describes today's behaviour and stands on its own. It is also the reference for moving this writing to jhdf (jhdf#654), which is why it exists now. SliceHelper becomes package private so a test can reach it. No other production change. --- .../vcell/export/server/ASCIIExporter.java | 4 +- .../server/ASCIIExporterHdf5SliceTest.java | 190 ++++++++++++++++++ .../solvers/CartesianMeshTestSupport.java | 30 +++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 vcell-core/src/test/java/cbit/vcell/export/server/ASCIIExporterHdf5SliceTest.java create mode 100644 vcell-core/src/test/java/cbit/vcell/solvers/CartesianMeshTestSupport.java diff --git a/vcell-core/src/main/java/cbit/vcell/export/server/ASCIIExporter.java b/vcell-core/src/main/java/cbit/vcell/export/server/ASCIIExporter.java index 7bb3473b32..fde513f9d0 100644 --- a/vcell-core/src/main/java/cbit/vcell/export/server/ASCIIExporter.java +++ b/vcell-core/src/main/java/cbit/vcell/export/server/ASCIIExporter.java @@ -1191,7 +1191,9 @@ private FileDataContainerID getPointsTimeSeries(PointsCurvesSlices pcs, long hdf } - private class SliceHelper { + // Package private rather than private so the export's HDF5 slice writing can be characterised by a + // test; it had none. + class SliceHelper { public long hdf5DataspaceIDSlice = -1; public long hdf5GroupVarID = -1; long hdf5DataspaceIDValues = -1; diff --git a/vcell-core/src/test/java/cbit/vcell/export/server/ASCIIExporterHdf5SliceTest.java b/vcell-core/src/test/java/cbit/vcell/export/server/ASCIIExporterHdf5SliceTest.java new file mode 100644 index 0000000000..aa00bc2580 --- /dev/null +++ b/vcell-core/src/test/java/cbit/vcell/export/server/ASCIIExporterHdf5SliceTest.java @@ -0,0 +1,190 @@ +package cbit.vcell.export.server; + +import cbit.image.VCImageUncompressed; +import cbit.vcell.geometry.RegionImage; +import cbit.vcell.math.VariableType; +import cbit.vcell.solvers.CartesianMesh; +import cbit.vcell.solvers.CartesianMeshTestSupport; +import hdf.hdf5lib.H5; +import hdf.hdf5lib.HDF5Constants; +import io.jhdf.HdfFile; +import io.jhdf.api.Dataset; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.vcell.util.Extent; +import org.vcell.util.Origin; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +/** + * Characterises what the ASCII/HDF5 export actually writes for spatial data, one timepoint at a time. + *

+ * The export had no tests at all, and its HDF5 writing is being ported off the native hdf.hdf5lib binding to jhdf + * (jhdf#654). These assertions are the reference the port has to reproduce: the dataset's name, its shape, and + * which value ends up at which index after the slice extraction reorders the raw simulation data. + */ +@Tag("Fast") +public class ASCIIExporterHdf5SliceTest { + + private static final int SIZE_X = 4; + private static final int SIZE_Y = 3; + private static final int SIZE_Z = 2; + private static final int TIME_COUNT = 3; + + /** + * Two subvolumes split along x, so the mesh has real membrane elements between them. A uniform image has none, + * and membrane data would then be zero length -- which the type inference still calls MEMBRANE, so the test + * would pass while measuring nothing. + */ + private static CartesianMesh mesh() throws Exception { + Extent extent = new Extent(1, 1, 1); + Origin origin = new Origin(0, 0, 0); + byte[] pixels = new byte[SIZE_X * SIZE_Y * SIZE_Z]; + for (int z = 0; z < SIZE_Z; z++) { + for (int y = 0; y < SIZE_Y; y++) { + for (int x = 0; x < SIZE_X; x++) { + pixels[z * SIZE_X * SIZE_Y + y * SIZE_X + x] = (byte) (x < SIZE_X / 2 ? 0 : 1); + } + } + } + VCImageUncompressed image = new VCImageUncompressed(null, pixels, extent, SIZE_X, SIZE_Y, SIZE_Z); + RegionImage regionImage = new RegionImage(image, 3, extent, origin, 0.5); + return CartesianMesh.createSimpleCartesianMesh(origin, extent, + new org.vcell.util.ISize(SIZE_X, SIZE_Y, SIZE_Z), regionImage); + } + + /** Drives the export's slice writing for every timepoint and returns the file it wrote. */ + private static Path writeTimepoints(CartesianMesh mesh, int sliceNumber, DataForTime dataForTime) + throws Exception { + Path file = Files.createTempFile("asciiExport", ".hdf5"); + Files.deleteIfExists(file); + + FileDataContainerManager containers = new FileDataContainerManager(); + long fileId = H5.H5Fcreate(file.toString(), HDF5Constants.H5F_ACC_TRUNC, + HDF5Constants.H5P_DEFAULT, HDF5Constants.H5P_DEFAULT); + long groupId = H5.H5Gcreate(fileId, "SimID_1", HDF5Constants.H5P_DEFAULT, + HDF5Constants.H5P_DEFAULT, HDF5Constants.H5P_DEFAULT); + + ASCIIExporter exporter = new ASCIIExporter(null); + ASCIIExporter.SliceHelper sliceHelper = + exporter.new SliceHelper(TIME_COUNT, true, "XY", sliceNumber, false, mesh); + sliceHelper.setHDF5GroupVarID(groupId, "Ran_cyt"); + for (int timeIndex = 0; timeIndex < TIME_COUNT; timeIndex++) { + sliceHelper.populate(containers, containers.getNewFileDataContainerID(), dataForTime.at(timeIndex)); + } + sliceHelper.closeHDF5GroupAndValues(); + H5.H5Gclose(groupId); + H5.H5Fclose(fileId); + return file; + } + + @FunctionalInterface + private interface DataForTime { + double[] at(int timeIndex); + } + + /** Volume data for one timepoint, valued so the index it came from is recoverable. */ + private static double[] volumeData(int timeIndex) { + double[] data = new double[SIZE_X * SIZE_Y * SIZE_Z]; + for (int i = 0; i < data.length; i++) { + data[i] = timeIndex * 1000 + i; + } + return data; + } + + @Test + public void wholeVolumePerTimepoint() throws Exception { + CartesianMesh mesh = mesh(); + Path file = writeTimepoints(mesh, -1, ASCIIExporterHdf5SliceTest::volumeData); + + try (HdfFile hdfFile = new HdfFile(file)) { + Dataset dataset = hdfFile.getDatasetByPath("SimID_1/Ran_cyt/DataValues (XYZT)"); + // {outer, inner, slices, time} for an xy slice plane over the whole volume + assertArrayEquals(new int[]{SIZE_Y, SIZE_X, SIZE_Z, TIME_COUNT}, dataset.getDimensions()); + + double[][][][] values = (double[][][][]) dataset.getData(); + for (int timeIndex = 0; timeIndex < TIME_COUNT; timeIndex++) { + for (int z = 0; z < SIZE_Z; z++) { + for (int outer = 0; outer < SIZE_Y; outer++) { + for (int inner = 0; inner < SIZE_X; inner++) { + double expected = timeIndex * 1000 + z * SIZE_X * SIZE_Y + outer * SIZE_X + inner; + assertEquals(expected, values[outer][inner][z][timeIndex], + "value at outer=" + outer + " inner=" + inner + " z=" + z + " t=" + timeIndex); + } + } + } + } + } + Files.deleteIfExists(file); + } + + /** + * One slice of the volume rather than all of it. The dataset drops the Z dimension and its name loses the Z, + * so a single-slice export is a different shape and a different name from a whole-volume one. + */ + @Test + public void singleSlicePerTimepoint() throws Exception { + final int sliceNumber = 1; + CartesianMesh mesh = mesh(); + Path file = writeTimepoints(mesh, sliceNumber, ASCIIExporterHdf5SliceTest::volumeData); + + try (HdfFile hdfFile = new HdfFile(file)) { + Dataset dataset = hdfFile.getDatasetByPath("SimID_1/Ran_cyt/DataValues (XYT)"); + assertArrayEquals(new int[]{SIZE_Y, SIZE_X, TIME_COUNT}, dataset.getDimensions()); + + double[][][] values = (double[][][]) dataset.getData(); + for (int timeIndex = 0; timeIndex < TIME_COUNT; timeIndex++) { + for (int outer = 0; outer < SIZE_Y; outer++) { + for (int inner = 0; inner < SIZE_X; inner++) { + double expected = + timeIndex * 1000 + sliceNumber * SIZE_X * SIZE_Y + outer * SIZE_X + inner; + assertEquals(expected, values[outer][inner][timeIndex], + "value at outer=" + outer + " inner=" + inner + " t=" + timeIndex); + } + } + } + } + Files.deleteIfExists(file); + } + + /** + * Membrane data is not reordered at all: it is written as it arrives, one column per timepoint. The variable + * type is inferred from the data's length, so this depends on the mesh actually having membrane elements. + */ + @Test + public void membranePerTimepoint() throws Exception { + final int membraneLength = 7; + CartesianMesh mesh = CartesianMeshTestSupport.withMembraneElementCount(mesh(), membraneLength); + assertEquals(membraneLength, mesh.getDataLength(VariableType.MEMBRANE), + "the test mesh has no membrane elements, so this would measure nothing"); + assertNotEquals(mesh.getDataLength(VariableType.VOLUME), membraneLength, + "volume is checked first, so equal lengths would classify membrane data as volume"); + + Path file = writeTimepoints(mesh, -1, timeIndex -> { + double[] data = new double[membraneLength]; + for (int i = 0; i < data.length; i++) { + data[i] = timeIndex * 1000 + i; + } + return data; + }); + + try (HdfFile hdfFile = new HdfFile(file)) { + Dataset dataset = hdfFile.getDatasetByPath("SimID_1/Ran_cyt/DataValues (MT)"); + assertArrayEquals(new int[]{membraneLength, TIME_COUNT}, dataset.getDimensions()); + + double[][] values = (double[][]) dataset.getData(); + for (int timeIndex = 0; timeIndex < TIME_COUNT; timeIndex++) { + for (int i = 0; i < membraneLength; i++) { + assertEquals(timeIndex * 1000 + i, values[i][timeIndex], + "value at element=" + i + " t=" + timeIndex); + } + } + } + Files.deleteIfExists(file); + } +} diff --git a/vcell-core/src/test/java/cbit/vcell/solvers/CartesianMeshTestSupport.java b/vcell-core/src/test/java/cbit/vcell/solvers/CartesianMeshTestSupport.java new file mode 100644 index 0000000000..79a00b70b3 --- /dev/null +++ b/vcell-core/src/test/java/cbit/vcell/solvers/CartesianMeshTestSupport.java @@ -0,0 +1,30 @@ +package cbit.vcell.solvers; + + + +/** + * Test-only access to {@link CartesianMesh} internals, from inside its package. + *

+ * Membrane elements are only ever populated by the Geometry based factory, which needs a generated surface + * collection. Code that merely classifies data by length, or writes membrane data through unchanged, does not care + * what the elements are - only how many there are. + */ +public final class CartesianMeshTestSupport { + + private CartesianMeshTestSupport() { + } + + /** + * Gives a mesh the stated number of membrane elements, so data of that length is classified as + * {@link cbit.vcell.math.VariableType#MEMBRANE}. The elements themselves are left null: any code that reads + * them needs a real geometry and should not be using this. + * + * @param mesh the mesh to modify + * @param count the number of membrane elements to report + * @return the same mesh, for chaining + */ + public static CartesianMesh withMembraneElementCount(CartesianMesh mesh, int count) { + mesh.membraneElements = new MembraneElement[count]; + return mesh; + } +}