From 198b1c0138dc79e177ac96d4121e54f078ba6744 Mon Sep 17 00:00:00 2001 From: Houjun Tang Date: Thu, 16 Apr 2026 16:31:17 -0700 Subject: [PATCH 1/2] Fix ESSI output time axis and coordinate offsets --- convert.py | 64 +++++++++++++++++++---------------- tests/test_convert_helpers.py | 54 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 tests/test_convert_helpers.py diff --git a/convert.py b/convert.py index f6178d5..0119b5c 100755 --- a/convert.py +++ b/convert.py @@ -651,18 +651,21 @@ def write_to_hdf5_range_1d(h5_fname, gname, dname, data, mystart, myend): dset[mystart:myend] = data[:] h5file.close() -def write_to_hdf5_range_2d(h5_fname, gname, dname, data, mystart, myend): - h5file = h5py.File(h5_fname, 'r+') - if gname == '/': - dset = h5file[dname] +def write_to_hdf5_range_2d(h5_fname, gname, dname, data, mystart, myend): + h5file = h5py.File(h5_fname, 'r+') + if gname == '/': + dset = h5file[dname] else: grp = h5file[gname] dset = grp[dname] - #print('mystart=%d, myend=%d' %(mystart, myend)) - dset[mystart:myend,:] = data[:,:] - h5file.close() - + #print('mystart=%d, myend=%d' %(mystart, myend)) + dset[mystart:myend,:] = data[:,:] + h5file.close() + +def get_flat_coord_range(coord_offset, ncoord, width=3): + return coord_offset * width, (coord_offset + ncoord) * width + def create_hdf5_opensees(h5_fname, ncoord, tsteprange, essi_dt, gen_vel, gen_acc, gen_dis, extra_dname): tstep = tsteprange.step nstep = len(tsteprange) @@ -717,8 +720,8 @@ def create_hdf5_csv(h5_fname, ncoord, tsteprange, essi_dt, gen_vel, gen_acc, gen h5file.close() -def create_hdf5_essi(h5_fname, ncoord, nstep, dt, gen_vel, gen_acc, gen_dis, extra_dname): - h5file = h5py.File(h5_fname, 'r+') +def create_hdf5_essi(h5_fname, ncoord, nstep, dt, gen_vel, gen_acc, gen_dis, extra_dname): + h5file = h5py.File(h5_fname, 'r+') if gen_vel: dset = h5file.create_dataset('Velocity', (ncoord*3, nstep), dtype='f4') @@ -727,9 +730,9 @@ def create_hdf5_essi(h5_fname, ncoord, nstep, dt, gen_vel, gen_acc, gen_dis, ext if gen_dis: dset = h5file.create_dataset('Displacements', (ncoord*3, nstep), dtype='f4') - timeseq = np.linspace(0, nstep*dt, nstep+1) - - h5file.create_dataset('Time', data=timeseq, dtype='i4') + timeseq = np.arange(nstep, dtype='f8') * dt + + h5file.create_dataset('Time', data=timeseq, dtype='f8') h5file.close() @@ -1438,29 +1441,30 @@ def generate_acc_dis_time(ssi_fname, coord_sys, ref_coord, user_x0, user_y0, use if mpi_rank != mpi_size-1: comm.send(my_ncoord, dest=mpi_rank+1, tag=11) - elif output_format == "ESSI": - if mpi_rank == 0: - create_hdf5_essi(output_fname, n_coord, nsteps, dt, gen_vel, gen_acc, gen_dis, extra_dname) - # Write to the template file - if my_ncoord[0] > 0: - write_to_hdf5_range_1d(output_fname, '/', 'Coordinates', my_user_coordinates.reshape(my_ncoord[0]*3), my_offset, (my_offset+my_ncoord[0])*3) - write_to_hdf5_range_1d(output_fname, '/', extra_dname, is_boundary, my_offset, my_offset+my_ncoord[0]) - if gen_acc: - write_to_hdf5_range(output_fname, '/', 'Accelerations', output_acc_all[:,tsteprange], my_offset*3, (my_offset+my_ncoord[0])*3) - if gen_dis: + elif output_format == "ESSI": + coords_start, coords_end = get_flat_coord_range(my_offset, my_ncoord[0]) + if mpi_rank == 0: + create_hdf5_essi(output_fname, n_coord, nsteps, dt, gen_vel, gen_acc, gen_dis, extra_dname) + # Write to the template file + if my_ncoord[0] > 0: + write_to_hdf5_range_1d(output_fname, '/', 'Coordinates', my_user_coordinates.reshape(my_ncoord[0]*3), coords_start, coords_end) + write_to_hdf5_range_1d(output_fname, '/', extra_dname, is_boundary, my_offset, my_offset+my_ncoord[0]) + if gen_acc: + write_to_hdf5_range(output_fname, '/', 'Accelerations', output_acc_all[:,tsteprange], my_offset*3, (my_offset+my_ncoord[0])*3) + if gen_dis: write_to_hdf5_range(output_fname, '/', 'Displacements', output_dis_all[:,tsteprange], my_offset*3, (my_offset+my_ncoord[0])*3) if gen_vel: write_to_hdf5_range(output_fname, '/', 'Velocity', output_vel_all[:,tsteprange], my_offset*3, (my_offset+my_ncoord[0])*3) if mpi_size > 1: comm.send(my_ncoord, dest=1, tag=111) - else: - data = comm.recv(source=mpi_rank-1, tag=111) - if my_ncoord[0] > 0: - write_to_hdf5_range_1d(output_fname, '/', 'Coordinates', my_user_coordinates.reshape(my_ncoord[0]*3), my_offset, (my_offset+my_ncoord[0])*3) - write_to_hdf5_range_1d(output_fname, '/', extra_dname, is_boundary, my_offset, my_offset+my_ncoord[0]) - if gen_acc: - write_to_hdf5_range(output_fname, '/', 'Accelerations', output_acc_all[:,tsteprange], my_offset*3, (my_offset+my_ncoord[0])*3) + else: + data = comm.recv(source=mpi_rank-1, tag=111) + if my_ncoord[0] > 0: + write_to_hdf5_range_1d(output_fname, '/', 'Coordinates', my_user_coordinates.reshape(my_ncoord[0]*3), coords_start, coords_end) + write_to_hdf5_range_1d(output_fname, '/', extra_dname, is_boundary, my_offset, my_offset+my_ncoord[0]) + if gen_acc: + write_to_hdf5_range(output_fname, '/', 'Accelerations', output_acc_all[:,tsteprange], my_offset*3, (my_offset+my_ncoord[0])*3) if gen_dis: write_to_hdf5_range(output_fname, '/', 'Displacements', output_dis_all[:,tsteprange], my_offset*3, (my_offset+my_ncoord[0])*3) if gen_vel: diff --git a/tests/test_convert_helpers.py b/tests/test_convert_helpers.py new file mode 100644 index 0000000..98699a2 --- /dev/null +++ b/tests/test_convert_helpers.py @@ -0,0 +1,54 @@ +import sys +import tempfile +import types +import unittest +from pathlib import Path + +import h5py +import numpy as np + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +mpi4py_stub = types.ModuleType("mpi4py") +mpi4py_stub.MPI = types.SimpleNamespace() +sys.modules.setdefault("mpi4py", mpi4py_stub) +sys.modules.setdefault("hdf5plugin", types.ModuleType("hdf5plugin")) + +import convert + + +class ConvertHelperTests(unittest.TestCase): + def test_get_flat_coord_range_scales_node_offsets_by_xyz_width(self): + self.assertEqual(convert.get_flat_coord_range(0, 2), (0, 6)) + self.assertEqual(convert.get_flat_coord_range(2, 3), (6, 15)) + + def test_create_hdf5_essi_writes_float_time_axis_matching_motion_length(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "template.h5" + with h5py.File(path, "w"): + pass + + convert.create_hdf5_essi( + str(path), + ncoord=2, + nstep=3, + dt=0.25, + gen_vel=True, + gen_acc=True, + gen_dis=True, + extra_dname="unused", + ) + + with h5py.File(path, "r") as h5file: + self.assertEqual(h5file["Velocity"].shape, (6, 3)) + self.assertEqual(h5file["Accelerations"].shape, (6, 3)) + self.assertEqual(h5file["Displacements"].shape, (6, 3)) + self.assertEqual(h5file["Time"].shape, (3,)) + self.assertEqual(h5file["Time"].dtype, np.dtype("float64")) + np.testing.assert_allclose(h5file["Time"][:], [0.0, 0.25, 0.5]) + + +if __name__ == "__main__": + unittest.main() From 8d7a7328d8aca6d43c8494c8bfc63c4794d89e10 Mon Sep 17 00:00:00 2001 From: Houjun Tang Date: Thu, 16 Apr 2026 16:42:56 -0700 Subject: [PATCH 2/2] Add fixture conversion coverage to CI --- .github/workflows/ci.yml | 44 +++++++++++++-- tests/test_convert_fixture_data.py | 90 ++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 tests/test_convert_fixture_data.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4730014..4c37849 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: pull_request: jobs: - cli-parser-test: + unit-tests: runs-on: ubuntu-latest steps: @@ -19,10 +19,46 @@ jobs: with: python-version: "3.11" + - name: Install MPI Runtime + run: | + sudo apt-get update + sudo apt-get install -y openmpi-bin libopenmpi-dev + - name: Install Test Dependencies run: | python -m pip install --upgrade pip - python -m pip install numpy scipy h5py pandas matplotlib + python -m pip install numpy scipy h5py pandas matplotlib mpi4py hdf5plugin + + - name: Run Unit Tests + run: python -m unittest discover -s tests -p "test_*.py" + + - name: Run MPI Fixture Conversion Test + env: + MPLCONFIGDIR: ${{ runner.temp }}/mplconfig + run: | + mkdir -p "$MPLCONFIGDIR" "$RUNNER_TEMP/convert-out" + mpirun -n 1 python convert.py \ + -h5 template/h5NodeCrds.h5 \ + --ssi tests/data/small.ssi \ + -c template/motion_setting.csv \ + -P "$RUNNER_TEMP/convert-out" + python - <<'PY' + import os + import h5py + import numpy as np + + output_path = os.path.join(os.environ["RUNNER_TEMP"], "convert-out", "h5NodeCrds_motion.h5") + reference_path = "tests/data/h5NodeMotion.h5" + + with h5py.File(output_path, "r") as output_h5, h5py.File(reference_path, "r") as reference_h5: + np.testing.assert_allclose(output_h5["acceleration"][:], reference_h5["acceleration"][:]) + np.testing.assert_allclose(output_h5["xyz"][:], reference_h5["xyz"][:]) + np.testing.assert_array_equal(output_h5["nodeTag"][:], reference_h5["nodeTag"][:]) + assert output_h5["velocity"].shape == (108, 600) + assert output_h5["displacement"].shape == (108, 600) - - name: Run CLI Parser Test - run: python -m unittest tests.test_convert_cli + dt = float(output_h5["dt"][()]) + tend = float(output_h5["tend"][()]) + expected_tend = dt * (output_h5["acceleration"].shape[1] - 1) + assert abs(tend - expected_tend) < 1e-12 + PY diff --git a/tests/test_convert_fixture_data.py b/tests/test_convert_fixture_data.py new file mode 100644 index 0000000..8e9d6bd --- /dev/null +++ b/tests/test_convert_fixture_data.py @@ -0,0 +1,90 @@ +import os +import sys +import tempfile +import types +import unittest +from pathlib import Path + +import h5py +import numpy as np + + +MPLCONFIGDIR = Path(tempfile.gettempdir()) / "mplconfig-convert-tests" +MPLCONFIGDIR.mkdir(exist_ok=True) +os.environ.setdefault("MPLCONFIGDIR", str(MPLCONFIGDIR)) +os.environ.setdefault("XDG_CACHE_HOME", tempfile.gettempdir()) + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + + +class _FakeComm: + def Barrier(self): + return None + + def Allgather(self, sendbuf, recvbuf): + recvbuf[0][0] = sendbuf[0][0] + + def send(self, *_args, **_kwargs): + raise AssertionError("send() should not be called in serial tests") + + def recv(self, *_args, **_kwargs): + raise AssertionError("recv() should not be called in serial tests") + + +mpi4py_stub = types.ModuleType("mpi4py") +mpi4py_stub.MPI = types.SimpleNamespace(COMM_WORLD=_FakeComm(), INT=object()) +sys.modules.setdefault("mpi4py", mpi4py_stub) +sys.modules.setdefault("hdf5plugin", types.ModuleType("hdf5plugin")) + +import convert + + +class ConvertFixtureDataTests(unittest.TestCase): + def setUp(self): + convert.MPI = mpi4py_stub.MPI + self.sample_ssi = REPO_ROOT / "tests" / "data" / "small.ssi" + self.sample_h5 = REPO_ROOT / "template" / "h5NodeCrds.h5" + self.sample_csv = REPO_ROOT / "template" / "motion_setting.csv" + self.reference_output = REPO_ROOT / "tests" / "data" / "h5NodeMotion.h5" + + def test_convert_h5_matches_checked_in_fixture_data(self): + with tempfile.TemporaryDirectory() as tmpdir: + ref_coord, start_t, end_t, tstep, rotate_angle, zero_motion_dir = convert.get_csv_meta( + str(self.sample_csv) + ) + + convert.convert_h5( + str(self.sample_h5), + str(self.sample_ssi), + tmpdir, + ref_coord, + start_t, + end_t, + tstep, + rotate_angle, + zero_motion_dir, + False, + 0, + 1, + False, + ) + + output_path = Path(tmpdir) / "h5NodeCrds_motion.h5" + self.assertTrue(output_path.exists(), f"Missing output file: {output_path}") + + with h5py.File(output_path, "r") as output_h5, h5py.File(self.reference_output, "r") as reference_h5: + self.assertEqual(output_h5["velocity"].shape, (108, 600)) + self.assertEqual(output_h5["displacement"].shape, (108, 600)) + np.testing.assert_allclose(output_h5["acceleration"][:], reference_h5["acceleration"][:]) + np.testing.assert_allclose(output_h5["xyz"][:], reference_h5["xyz"][:]) + np.testing.assert_array_equal(output_h5["nodeTag"][:], reference_h5["nodeTag"][:]) + self.assertEqual(float(output_h5["dt"][()]), float(reference_h5["dt"][()])) + self.assertEqual(float(output_h5["tstart"][()]), float(reference_h5["tstart"][()])) + + expected_tend = float(output_h5["dt"][()]) * (output_h5["acceleration"].shape[1] - 1) + self.assertAlmostEqual(float(output_h5["tend"][()]), expected_tend) + + +if __name__ == "__main__": + unittest.main()