diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md index e78f244..bef2b4e 100644 --- a/MIGRATION_PLAN.md +++ b/MIGRATION_PLAN.md @@ -338,27 +338,32 @@ cpu_features_t cfd_get_cpu_features(void); **Actual effort:** 1 day -### Phase 3: Add Derived Fields & Statistics (Important) +### Phase 3: Add Derived Fields & Statistics (Important) ✅ COMPLETED **Priority:** P1 - Useful for post-processing +**Status:** Completed on 2026-01-01 + **Tasks:** -- [ ] **3.1 Create DerivedFields class** - - Wrapper for `derived_fields` struct - - Properties for velocity_magnitude array - - Properties for field statistics +- [x] **3.1 Implement field statistics function** + - `calculate_field_stats(data)` - Compute min, max, avg, sum for a field + - Returns dict with 'min', 'max', 'avg', 'sum' keys + +- [x] **3.2 Implement velocity magnitude computation** + - `compute_velocity_magnitude(u, v, nx, ny)` - Compute sqrt(u^2 + v^2) + - Returns list of velocity magnitudes -- [ ] **3.2 Implement statistics functions** - - `compute_velocity_magnitude(field)` - - `compute_statistics(field)` - - Return `FieldStats` named tuple +- [x] **3.3 Implement comprehensive flow statistics** + - `compute_flow_statistics(u, v, p, nx, ny)` - Statistics for all flow components + - Returns dict with 'u', 'v', 'p', 'velocity_magnitude' stats -- [ ] **3.3 Add to simulation workflow** - - Automatic derived field computation after step - - Access via `sim.derived_fields` +- [x] **3.4 Add tests** + - Created `tests/test_derived_fields.py` with comprehensive tests + - Tests for all three functions with edge cases + - Proper error handling tests (empty lists, wrong types, size mismatches) -**Estimated effort:** 1-2 days +**Actual effort:** < 1 day ### Phase 4: Add Error Handling API (Important) @@ -541,13 +546,13 @@ find_library(CFD_LIBRARY cfd_library) # Unified library name | Phase 1: Breaking Changes | ~~2-3 days~~ ✅ 1 day | ~~2-3 days~~ 1 day | | Phase 2: Boundary Conditions | ~~3-4 days~~ ✅ 1 day | ~~5-7 days~~ 2 days | | Phase 2.5: CI/Build System (v0.1.6) | ✅ 1 day | 3 days | -| Phase 3: Derived Fields | 1-2 days | 4-5 days | -| Phase 4: Error Handling | 1 day | 5-6 days | -| Phase 5: Backend Availability (v0.1.6) | ✅ 0.5 days | 3.5 days | -| Phase 6: CPU Features | 1 day | 4.5 days | -| Phase 7: Docs & Tests | 2 days | 6.5 days | +| Phase 3: Derived Fields | ~~1-2 days~~ ✅ < 1 day | 3.5 days | +| Phase 4: Error Handling | 1 day | 4.5 days | +| Phase 5: Backend Availability (v0.1.6) | ✅ 0.5 days | 4 days | +| Phase 6: CPU Features | 1 day | 5 days | +| Phase 7: Docs & Tests | 2 days | 7 days | -**Total estimated effort:** ~~9-10 days~~ 6.5 days (3.5 days completed) +**Total estimated effort:** ~~9-10 days~~ ~7 days (4 days completed) --- diff --git a/cfd_python/__init__.py b/cfd_python/__init__.py index e863e39..1ca7d9c 100644 --- a/cfd_python/__init__.py +++ b/cfd_python/__init__.py @@ -62,6 +62,11 @@ - bc_apply_outlet_scalar(field, nx, ny, edge): Zero-gradient outlet - bc_apply_outlet_velocity(u, v, nx, ny, edge): Zero-gradient outlet +Derived fields and statistics: + - calculate_field_stats(data): Compute min, max, avg, sum for a field + - compute_velocity_magnitude(u, v, nx, ny): Compute sqrt(u^2 + v^2) + - compute_flow_statistics(u, v, p, nx, ny): Statistics for all flow components + Solver backend availability (v0.1.6): Backends: - BACKEND_SCALAR: Basic scalar CPU implementation @@ -147,6 +152,10 @@ "bc_apply_inlet_parabolic", "bc_apply_outlet_scalar", "bc_apply_outlet_velocity", + # Derived fields API (Phase 3) + "calculate_field_stats", + "compute_velocity_magnitude", + "compute_flow_statistics", # Solver backend constants (v0.1.6) "BACKEND_SCALAR", "BACKEND_SIMD", diff --git a/cfd_python/_loader.py b/cfd_python/_loader.py index 078d045..1bd500e 100644 --- a/cfd_python/_loader.py +++ b/cfd_python/_loader.py @@ -35,17 +35,14 @@ def load_extension(): from .cfd_python import ( BACKEND_CUDA, BACKEND_OMP, - # Solver backend constants (v0.1.6) BACKEND_SCALAR, BACKEND_SIMD, - # Boundary condition backends BC_BACKEND_AUTO, BC_BACKEND_CUDA, BC_BACKEND_OMP, BC_BACKEND_SCALAR, BC_BACKEND_SIMD, BC_EDGE_BOTTOM, - # Boundary edges BC_EDGE_LEFT, BC_EDGE_RIGHT, BC_EDGE_TOP, @@ -54,7 +51,6 @@ def load_extension(): BC_TYPE_NEUMANN, BC_TYPE_NOSLIP, BC_TYPE_OUTLET, - # Boundary condition types BC_TYPE_PERIODIC, CFD_ERROR, CFD_ERROR_DIVERGED, @@ -63,7 +59,6 @@ def load_extension(): CFD_ERROR_MAX_ITER, CFD_ERROR_NOMEM, CFD_ERROR_UNSUPPORTED, - # Error handling API CFD_SUCCESS, OUTPUT_CSV_CENTERLINE, OUTPUT_CSV_STATISTICS, @@ -72,7 +67,6 @@ def load_extension(): OUTPUT_VELOCITY, OUTPUT_VELOCITY_MAGNITUDE, backend_get_name, - # Solver backend availability functions (v0.1.6) backend_is_available, bc_apply_dirichlet, bc_apply_inlet_parabolic, @@ -83,12 +77,13 @@ def load_extension(): bc_apply_scalar, bc_apply_velocity, bc_backend_available, - # Boundary condition functions bc_get_backend, bc_get_backend_name, bc_set_backend, + calculate_field_stats, clear_error, - # Core functions + compute_flow_statistics, + compute_velocity_magnitude, create_grid, get_available_backends, get_default_solver_params, @@ -174,6 +169,10 @@ def load_extension(): "bc_apply_inlet_parabolic": bc_apply_inlet_parabolic, "bc_apply_outlet_scalar": bc_apply_outlet_scalar, "bc_apply_outlet_velocity": bc_apply_outlet_velocity, + # Derived fields API (Phase 3) + "calculate_field_stats": calculate_field_stats, + "compute_velocity_magnitude": compute_velocity_magnitude, + "compute_flow_statistics": compute_flow_statistics, # Solver backend constants (v0.1.6) "BACKEND_SCALAR": BACKEND_SCALAR, "BACKEND_SIMD": BACKEND_SIMD, diff --git a/src/cfd_python.c b/src/cfd_python.c index b27ca8f..161c85f 100644 --- a/src/cfd_python.c +++ b/src/cfd_python.c @@ -1591,6 +1591,302 @@ static PyObject* bc_apply_outlet_velocity_py(PyObject* self, PyObject* args, PyO Py_RETURN_NONE; } +//============================================================================= +// DERIVED FIELDS API (Phase 3) +//============================================================================= + +/* + * Calculate field statistics (min, max, avg, sum) + */ +static PyObject* calculate_field_stats_py(PyObject* self, PyObject* args) { + (void)self; + PyObject* data_list; + + if (!PyArg_ParseTuple(args, "O", &data_list)) { + return NULL; + } + + if (!PyList_Check(data_list)) { + PyErr_SetString(PyExc_TypeError, "data must be a list"); + return NULL; + } + + Py_ssize_t count = PyList_Size(data_list); + if (count == 0) { + PyErr_SetString(PyExc_ValueError, "data list cannot be empty"); + return NULL; + } + + // Convert list to C array + double* data = (double*)malloc((size_t)count * sizeof(double)); + if (data == NULL) { + PyErr_SetString(PyExc_MemoryError, "Failed to allocate data array"); + return NULL; + } + + for (Py_ssize_t i = 0; i < count; i++) { + PyObject* item = PyList_GetItem(data_list, i); + data[i] = PyFloat_AsDouble(item); + if (PyErr_Occurred()) { + free(data); + return NULL; + } + } + + // Calculate statistics + field_stats stats = calculate_field_statistics(data, (size_t)count); + free(data); + + // Return as dictionary + PyObject* result = PyDict_New(); + if (result == NULL) { + return NULL; + } + + PyObject* min_val = PyFloat_FromDouble(stats.min_val); + PyObject* max_val = PyFloat_FromDouble(stats.max_val); + PyObject* avg_val = PyFloat_FromDouble(stats.avg_val); + PyObject* sum_val = PyFloat_FromDouble(stats.sum_val); + + if (min_val == NULL || max_val == NULL || avg_val == NULL || sum_val == NULL) { + Py_XDECREF(min_val); + Py_XDECREF(max_val); + Py_XDECREF(avg_val); + Py_XDECREF(sum_val); + Py_DECREF(result); + return NULL; + } + + if (PyDict_SetItemString(result, "min", min_val) < 0 || + PyDict_SetItemString(result, "max", max_val) < 0 || + PyDict_SetItemString(result, "avg", avg_val) < 0 || + PyDict_SetItemString(result, "sum", sum_val) < 0) { + Py_DECREF(min_val); + Py_DECREF(max_val); + Py_DECREF(avg_val); + Py_DECREF(sum_val); + Py_DECREF(result); + return NULL; + } + + Py_DECREF(min_val); + Py_DECREF(max_val); + Py_DECREF(avg_val); + Py_DECREF(sum_val); + + return result; +} + +/* + * Compute velocity magnitude from u,v components + */ +static PyObject* compute_velocity_magnitude_py(PyObject* self, PyObject* args) { + (void)self; + PyObject* u_list; + PyObject* v_list; + size_t nx, ny; + + if (!PyArg_ParseTuple(args, "OOnn", &u_list, &v_list, &nx, &ny)) { + return NULL; + } + + if (!PyList_Check(u_list) || !PyList_Check(v_list)) { + PyErr_SetString(PyExc_TypeError, "u and v must be lists"); + return NULL; + } + + size_t size = nx * ny; + if ((size_t)PyList_Size(u_list) != size || (size_t)PyList_Size(v_list) != size) { + PyErr_Format(PyExc_ValueError, + "u and v size must match nx*ny (%zu), got %zd and %zd", + size, PyList_Size(u_list), PyList_Size(v_list)); + return NULL; + } + + // Create a temporary flow_field structure + flow_field* field = flow_field_create(nx, ny); + if (field == NULL) { + PyErr_SetString(PyExc_MemoryError, "Failed to allocate flow field"); + return NULL; + } + + // Copy u, v from lists + for (size_t i = 0; i < size; i++) { + field->u[i] = PyFloat_AsDouble(PyList_GetItem(u_list, i)); + field->v[i] = PyFloat_AsDouble(PyList_GetItem(v_list, i)); + if (PyErr_Occurred()) { + flow_field_destroy(field); + return NULL; + } + } + + // Create derived fields and compute velocity magnitude + derived_fields* derived = derived_fields_create(nx, ny); + if (derived == NULL) { + flow_field_destroy(field); + PyErr_SetString(PyExc_MemoryError, "Failed to allocate derived fields"); + return NULL; + } + + derived_fields_compute_velocity_magnitude(derived, field); + + // Create output list + PyObject* result = PyList_New(size); + if (result == NULL) { + derived_fields_destroy(derived); + flow_field_destroy(field); + return NULL; + } + + for (size_t i = 0; i < size; i++) { + PyObject* val = PyFloat_FromDouble(derived->velocity_magnitude[i]); + if (val == NULL) { + Py_DECREF(result); + derived_fields_destroy(derived); + flow_field_destroy(field); + return NULL; + } + PyList_SetItem(result, i, val); + } + + derived_fields_destroy(derived); + flow_field_destroy(field); + + return result; +} + +/* + * Compute all field statistics for flow field components + */ +static PyObject* compute_flow_statistics_py(PyObject* self, PyObject* args) { + (void)self; + PyObject* u_list; + PyObject* v_list; + PyObject* p_list; + size_t nx, ny; + + if (!PyArg_ParseTuple(args, "OOOnn", &u_list, &v_list, &p_list, &nx, &ny)) { + return NULL; + } + + if (!PyList_Check(u_list) || !PyList_Check(v_list) || !PyList_Check(p_list)) { + PyErr_SetString(PyExc_TypeError, "u, v, and p must be lists"); + return NULL; + } + + size_t size = nx * ny; + if ((size_t)PyList_Size(u_list) != size || + (size_t)PyList_Size(v_list) != size || + (size_t)PyList_Size(p_list) != size) { + PyErr_Format(PyExc_ValueError, + "All fields must have size nx*ny (%zu)", size); + return NULL; + } + + // Create a temporary flow_field structure + flow_field* field = flow_field_create(nx, ny); + if (field == NULL) { + PyErr_SetString(PyExc_MemoryError, "Failed to allocate flow field"); + return NULL; + } + + // Copy data from lists + for (size_t i = 0; i < size; i++) { + field->u[i] = PyFloat_AsDouble(PyList_GetItem(u_list, i)); + field->v[i] = PyFloat_AsDouble(PyList_GetItem(v_list, i)); + field->p[i] = PyFloat_AsDouble(PyList_GetItem(p_list, i)); + if (PyErr_Occurred()) { + flow_field_destroy(field); + return NULL; + } + } + + // Create derived fields and compute statistics + derived_fields* derived = derived_fields_create(nx, ny); + if (derived == NULL) { + flow_field_destroy(field); + PyErr_SetString(PyExc_MemoryError, "Failed to allocate derived fields"); + return NULL; + } + + // First compute velocity magnitude (required for vel_mag_stats) + derived_fields_compute_velocity_magnitude(derived, field); + // Then compute all statistics + derived_fields_compute_statistics(derived, field); + + // Build result dictionary with all statistics + PyObject* result = PyDict_New(); + if (result == NULL) { + derived_fields_destroy(derived); + flow_field_destroy(field); + return NULL; + } + + // Helper macro to add stats dict (properly handles reference counts and errors) + #define ADD_STATS(name, stats_struct) do { \ + PyObject* stats_dict = PyDict_New(); \ + if (stats_dict == NULL) { \ + Py_DECREF(result); \ + derived_fields_destroy(derived); \ + flow_field_destroy(field); \ + return NULL; \ + } \ + PyObject* tmp_min = PyFloat_FromDouble((stats_struct).min_val); \ + PyObject* tmp_max = PyFloat_FromDouble((stats_struct).max_val); \ + PyObject* tmp_avg = PyFloat_FromDouble((stats_struct).avg_val); \ + PyObject* tmp_sum = PyFloat_FromDouble((stats_struct).sum_val); \ + if (tmp_min == NULL || tmp_max == NULL || tmp_avg == NULL || tmp_sum == NULL) { \ + Py_XDECREF(tmp_min); \ + Py_XDECREF(tmp_max); \ + Py_XDECREF(tmp_avg); \ + Py_XDECREF(tmp_sum); \ + Py_DECREF(stats_dict); \ + Py_DECREF(result); \ + derived_fields_destroy(derived); \ + flow_field_destroy(field); \ + return NULL; \ + } \ + if (PyDict_SetItemString(stats_dict, "min", tmp_min) < 0 || \ + PyDict_SetItemString(stats_dict, "max", tmp_max) < 0 || \ + PyDict_SetItemString(stats_dict, "avg", tmp_avg) < 0 || \ + PyDict_SetItemString(stats_dict, "sum", tmp_sum) < 0) { \ + Py_DECREF(tmp_min); \ + Py_DECREF(tmp_max); \ + Py_DECREF(tmp_avg); \ + Py_DECREF(tmp_sum); \ + Py_DECREF(stats_dict); \ + Py_DECREF(result); \ + derived_fields_destroy(derived); \ + flow_field_destroy(field); \ + return NULL; \ + } \ + Py_DECREF(tmp_min); \ + Py_DECREF(tmp_max); \ + Py_DECREF(tmp_avg); \ + Py_DECREF(tmp_sum); \ + if (PyDict_SetItemString(result, name, stats_dict) < 0) { \ + Py_DECREF(stats_dict); \ + Py_DECREF(result); \ + derived_fields_destroy(derived); \ + flow_field_destroy(field); \ + return NULL; \ + } \ + Py_DECREF(stats_dict); \ + } while(0) + + ADD_STATS("u", derived->u_stats); + ADD_STATS("v", derived->v_stats); + ADD_STATS("p", derived->p_stats); + ADD_STATS("velocity_magnitude", derived->vel_mag_stats); + + #undef ADD_STATS + + derived_fields_destroy(derived); + flow_field_destroy(field); + + return result; +} + /* * Module definition */ @@ -1794,6 +2090,33 @@ static PyMethodDef cfd_python_methods[] = { " nx (int): Grid points in x direction\n" " ny (int): Grid points in y direction\n" " edge (int, optional): Boundary edge (default: BC_EDGE_RIGHT)"}, + // Derived Fields API (Phase 3) + {"calculate_field_stats", calculate_field_stats_py, METH_VARARGS, + "Calculate statistics (min, max, avg, sum) for a field.\n\n" + "Args:\n" + " data (list): Field data as flat list\n\n" + "Returns:\n" + " dict: Statistics with keys 'min', 'max', 'avg', 'sum'"}, + {"compute_velocity_magnitude", compute_velocity_magnitude_py, METH_VARARGS, + "Compute velocity magnitude from u and v components.\n\n" + "Args:\n" + " u (list): X-velocity field\n" + " v (list): Y-velocity field\n" + " nx (int): Grid points in x direction\n" + " ny (int): Grid points in y direction\n\n" + "Returns:\n" + " list: Velocity magnitude field (sqrt(u^2 + v^2))"}, + {"compute_flow_statistics", compute_flow_statistics_py, METH_VARARGS, + "Compute statistics for all flow field components.\n\n" + "Args:\n" + " u (list): X-velocity field\n" + " v (list): Y-velocity field\n" + " p (list): Pressure field\n" + " nx (int): Grid points in x direction\n" + " ny (int): Grid points in y direction\n\n" + "Returns:\n" + " dict: Statistics for 'u', 'v', 'p', 'velocity_magnitude'\n" + " Each contains 'min', 'max', 'avg', 'sum'"}, // Solver Backend Availability API (v0.1.6) {"backend_is_available", backend_is_available_py, METH_VARARGS, "Check if a solver backend is available at runtime.\n\n" diff --git a/tests/test_derived_fields.py b/tests/test_derived_fields.py new file mode 100644 index 0000000..2f4a0c8 --- /dev/null +++ b/tests/test_derived_fields.py @@ -0,0 +1,214 @@ +""" +Tests for derived fields and statistics API in cfd_python. +""" + +import math + +import pytest + +import cfd_python + + +class TestCalculateFieldStats: + """Test calculate_field_stats function""" + + def test_calculate_field_stats_basic(self): + """Test basic statistics calculation""" + data = [1.0, 2.0, 3.0, 4.0, 5.0] + stats = cfd_python.calculate_field_stats(data) + + assert isinstance(stats, dict) + assert "min" in stats + assert "max" in stats + assert "avg" in stats + assert "sum" in stats + + assert stats["min"] == 1.0 + assert stats["max"] == 5.0 + assert stats["avg"] == 3.0 + assert stats["sum"] == 15.0 + + def test_calculate_field_stats_single_value(self): + """Test statistics with single value""" + data = [42.0] + stats = cfd_python.calculate_field_stats(data) + + assert stats["min"] == 42.0 + assert stats["max"] == 42.0 + assert stats["avg"] == 42.0 + assert stats["sum"] == 42.0 + + def test_calculate_field_stats_negative_values(self): + """Test statistics with negative values""" + data = [-5.0, -2.0, 0.0, 2.0, 5.0] + stats = cfd_python.calculate_field_stats(data) + + assert stats["min"] == -5.0 + assert stats["max"] == 5.0 + assert stats["avg"] == 0.0 + assert stats["sum"] == 0.0 + + def test_calculate_field_stats_large_field(self): + """Test statistics with larger field""" + nx, ny = 10, 10 + data = [float(i) for i in range(nx * ny)] + stats = cfd_python.calculate_field_stats(data) + + assert stats["min"] == 0.0 + assert stats["max"] == 99.0 + assert abs(stats["avg"] - 49.5) < 1e-10 + assert stats["sum"] == 4950.0 + + def test_calculate_field_stats_empty_raises(self): + """Test that empty list raises ValueError""" + with pytest.raises(ValueError): + cfd_python.calculate_field_stats([]) + + def test_calculate_field_stats_wrong_type_raises(self): + """Test that non-list raises TypeError""" + with pytest.raises(TypeError): + cfd_python.calculate_field_stats("not a list") + + +class TestComputeVelocityMagnitude: + """Test compute_velocity_magnitude function""" + + def test_compute_velocity_magnitude_basic(self): + """Test basic velocity magnitude computation""" + nx, ny = 2, 2 + u = [3.0, 0.0, 0.0, 4.0] + v = [4.0, 1.0, 0.0, 3.0] + + result = cfd_python.compute_velocity_magnitude(u, v, nx, ny) + + assert isinstance(result, list) + assert len(result) == nx * ny + # sqrt(3^2 + 4^2) = 5 + assert abs(result[0] - 5.0) < 1e-10 + # sqrt(0^2 + 1^2) = 1 + assert abs(result[1] - 1.0) < 1e-10 + # sqrt(0^2 + 0^2) = 0 + assert abs(result[2] - 0.0) < 1e-10 + # sqrt(4^2 + 3^2) = 5 + assert abs(result[3] - 5.0) < 1e-10 + + def test_compute_velocity_magnitude_uniform(self): + """Test with uniform velocity field""" + nx, ny = 4, 4 + u = [1.0] * (nx * ny) + v = [1.0] * (nx * ny) + + result = cfd_python.compute_velocity_magnitude(u, v, nx, ny) + + # sqrt(1^2 + 1^2) = sqrt(2) + expected = math.sqrt(2.0) + for val in result: + assert abs(val - expected) < 1e-10 + + def test_compute_velocity_magnitude_zero_field(self): + """Test with zero velocity field""" + nx, ny = 3, 3 + u = [0.0] * (nx * ny) + v = [0.0] * (nx * ny) + + result = cfd_python.compute_velocity_magnitude(u, v, nx, ny) + + for val in result: + assert val == 0.0 + + def test_compute_velocity_magnitude_size_mismatch_raises(self): + """Test that mismatched sizes raise ValueError""" + nx, ny = 4, 4 + u = [1.0] * 16 + v = [1.0] * 8 # Wrong size + + with pytest.raises(ValueError): + cfd_python.compute_velocity_magnitude(u, v, nx, ny) + + def test_compute_velocity_magnitude_wrong_type_raises(self): + """Test that non-list raises TypeError""" + with pytest.raises(TypeError): + cfd_python.compute_velocity_magnitude("not list", [1.0], 1, 1) + + +class TestComputeFlowStatistics: + """Test compute_flow_statistics function""" + + def test_compute_flow_statistics_basic(self): + """Test basic flow statistics computation""" + nx, ny = 2, 2 + u = [1.0, 2.0, 3.0, 4.0] + v = [0.5, 1.0, 1.5, 2.0] + p = [100.0, 101.0, 102.0, 103.0] + + result = cfd_python.compute_flow_statistics(u, v, p, nx, ny) + + assert isinstance(result, dict) + assert "u" in result + assert "v" in result + assert "p" in result + assert "velocity_magnitude" in result + + # Check u stats + assert result["u"]["min"] == 1.0 + assert result["u"]["max"] == 4.0 + assert result["u"]["avg"] == 2.5 + assert result["u"]["sum"] == 10.0 + + # Check v stats + assert result["v"]["min"] == 0.5 + assert result["v"]["max"] == 2.0 + assert result["v"]["avg"] == 1.25 + assert result["v"]["sum"] == 5.0 + + # Check p stats + assert result["p"]["min"] == 100.0 + assert result["p"]["max"] == 103.0 + assert result["p"]["avg"] == 101.5 + assert result["p"]["sum"] == 406.0 + + def test_compute_flow_statistics_velocity_magnitude_included(self): + """Test that velocity magnitude stats are computed""" + nx, ny = 2, 2 + u = [3.0, 0.0, 0.0, 4.0] + v = [4.0, 0.0, 0.0, 3.0] + p = [0.0, 0.0, 0.0, 0.0] + + result = cfd_python.compute_flow_statistics(u, v, p, nx, ny) + + vel_mag = result["velocity_magnitude"] + # vel_mag values: [5.0, 0.0, 0.0, 5.0] + assert vel_mag["min"] == 0.0 + assert vel_mag["max"] == 5.0 + assert vel_mag["avg"] == 2.5 + assert vel_mag["sum"] == 10.0 + + def test_compute_flow_statistics_size_mismatch_raises(self): + """Test that mismatched sizes raise ValueError""" + nx, ny = 4, 4 + u = [1.0] * 16 + v = [1.0] * 16 + p = [1.0] * 8 # Wrong size + + with pytest.raises(ValueError): + cfd_python.compute_flow_statistics(u, v, p, nx, ny) + + def test_compute_flow_statistics_wrong_type_raises(self): + """Test that non-list raises TypeError""" + with pytest.raises(TypeError): + cfd_python.compute_flow_statistics([1.0], "not list", [1.0], 1, 1) + + +class TestDerivedFieldsExported: + """Test that all derived fields functions are properly exported""" + + def test_functions_in_all(self): + """Test all derived fields functions are in __all__""" + functions = [ + "calculate_field_stats", + "compute_velocity_magnitude", + "compute_flow_statistics", + ] + for func_name in functions: + assert func_name in cfd_python.__all__, f"{func_name} should be in __all__" + assert callable(getattr(cfd_python, func_name)), f"{func_name} should be callable"